Skip to main content

Architecture

Lumie's backend architecture is defined by code-level module boundaries inside one Spring Boot process. The key design decision is not service-to-service network separation, but disciplined dependency direction inside the monolith.

This page is a boundary-focused overview document. Use it to understand where business rules live, how modules are allowed to collaborate, and which shared code is load-bearing.

Source Paths

PathRole
lumie-backend/AGENTS.mdCanonical backend module layout, multi-tenancy rules, and path-level architectural rules
lumie-backend/app/src/main/java/com/lumie/app/LumieApplication.javaSingle @SpringBootApplication entrypoint
lumie-backend/settings.gradle.ktsCurrent module inclusion list
lumie-backend/libs/common/src/main/java/com/lumie/common/tenant/*Tenant context, request context, and RLS binding
lumie-backend/libs/internal-api/src/main/java/**Published in-process contracts between modules
lumie-backend/modules/*/src/main/java/**Module-owned domain, application, and adapter code

Boundary Model

Canonical Module Layout

lumie-backend/AGENTS.md defines the standard shape:

modules/{module}/src/main/java/com/lumie/{package}/
├── domain/{entity,vo,exception}/
├── application/{service,port/out,dto/{request,response}}/
└── adapter/{in/web,in/messaging,in/internal,out/persistence,out/external}

What each layer means in the current codebase:

  • domain: persistence-backed aggregates, value objects, and module-specific error codes. JPA annotations on aggregates are intentional in this monolith.
  • application/service: use-case orchestration, transaction boundaries, and cross-port coordination.
  • application/port/out: outbound dependencies owned by the module.
  • adapter/in/*: inbound transport or integration entrypoints.
  • adapter/out/*: persistence, storage, queue, cache, or external-service implementations.

Notably absent by design:

  • No application/port/in/*UseCase layer. Controllers inject application services directly.
  • No infrastructure/ package.
  • No direct imports of another module's domain/entity types.

Shared Libraries And Their Boundaries

LibraryWhat modules may use it forWhat it is not for
libs/commonTenant context, user context, base entities, exceptions, idempotency, logging, auth helpers, pagination utilitiesPublishing product-domain contracts
libs/internal-apiSynchronous in-process service interfaces and cross-module event recordsSharing persistence adapters or JPA entities
libs/messagingQueue, exchange, and routing-key constants for AMQP-backed flowsDeclaring queue topology or business orchestration

Allowed Collaboration Patterns

Synchronous module-to-module

Use libs/internal-api interfaces and an owning module's adapter/in/internal/*Adapter implementation.

Real examples from the current code:

  • modules/homepage/adapter/out/internal/TenantLookupAdapter depends on com.lumie.tenant.api.TenantService
  • modules/staff/application/service/StaffCommandService depends on AuthService, BillingService, ClassService, and ContentService
  • modules/exam/adapter/in/event/StudentRegisteredListener depends on ExamService

Asynchronous cross-module follow-up

Use Spring Modulith events persisted in public.event_publication, then consumed by @ApplicationModuleListener.

Current examples:

  • TenantCreatedEvent -> billing trial provisioning
  • OwnerRegisteredEvent -> owner staff bootstrap
  • StudentRegisteredEvent -> exam-result backfill

External process boundary

Treat workers and third-party HTTP APIs as outbound integrations:

  • exam -> grading-svc, report-svc, RabbitMQ, MinIO
  • ai -> chatbot-svc
  • billing -> Toss Payments and a currently stubbed Popbill adapter

Modules should not call each other through /v1/** routes.

Context Propagation Is Architectural, Not Incidental

The backend's architectural boundary is enforced partly in Java and partly in PostgreSQL, so context propagation is load-bearing:

  • JwtAuthenticationFilter and InternalHmacAuthFilter populate tenant and user context before controller code runs.
  • RlsTenantContextAspect binds app.tenant_id at @Transactional entry.
  • TenantAwareTaskDecoratorConfig copies tenant, user, MDC, and security context into Spring-managed async execution.
  • Call sites that bypass those paths must explicitly restore context with TenantContextHolder.withinContext(...).

Naming And Packaging Drift Worth Knowing

  • modules:class uses the Java package com.lumie.classroom.
  • The docs route Staff Service still points at the modules/staff Gradle subproject.
  • modules/activity-log exists on disk but is not included in settings.gradle.kts.

Those are documentation or packaging quirks, not separate runtime services.

Architectural Failure Modes

  • Importing another module's aggregate type bypasses the published boundary and couples repositories, transactions, and tenancy assumptions.
  • Calling external HTTP inside a long @Transactional method holds database work open across the network; backend rules explicitly forbid this.
  • Self-invoking a supposedly transactional helper can bypass Spring proxies and skip RLS binding. HomepageQueryService.Tx exists specifically to avoid that problem on the public homepage path.
  • Async code that is not Spring-managed, such as the AI module's dedicated virtual-thread executor, must re-establish context manually.

Verification Commands

cd /Users/bluemayne/Projects/Lumie/lumie-backend
./gradlew test
./gradlew :libs:common:test
./gradlew :modules:homepage:test
./gradlew :modules:staff:test

Useful boundary-focused tests:

  • libs/common/src/test/java/com/lumie/common/tenant/RlsTenantContextAspectIntegrationTest.java
  • modules/homepage/src/test/java/com/lumie/homepage/application/service/HomepageQueryServiceTest.java
  • modules/staff/src/test/java/com/lumie/staff/application/service/StaffCommandServiceTest.java