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
| Path | Role |
|---|---|
lumie-backend/AGENTS.md | Canonical backend module layout, multi-tenancy rules, and path-level architectural rules |
lumie-backend/app/src/main/java/com/lumie/app/LumieApplication.java | Single @SpringBootApplication entrypoint |
lumie-backend/settings.gradle.kts | Current 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/*UseCaselayer. Controllers inject application services directly. - No
infrastructure/package. - No direct imports of another module's
domain/entitytypes.
Shared Libraries And Their Boundaries
| Library | What modules may use it for | What it is not for |
|---|---|---|
libs/common | Tenant context, user context, base entities, exceptions, idempotency, logging, auth helpers, pagination utilities | Publishing product-domain contracts |
libs/internal-api | Synchronous in-process service interfaces and cross-module event records | Sharing persistence adapters or JPA entities |
libs/messaging | Queue, exchange, and routing-key constants for AMQP-backed flows | Declaring 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/TenantLookupAdapterdepends oncom.lumie.tenant.api.TenantServicemodules/staff/application/service/StaffCommandServicedepends onAuthService,BillingService,ClassService, andContentServicemodules/exam/adapter/in/event/StudentRegisteredListenerdepends onExamService
Asynchronous cross-module follow-up
Use Spring Modulith events persisted in public.event_publication, then
consumed by @ApplicationModuleListener.
Current examples:
TenantCreatedEvent-> billing trial provisioningOwnerRegisteredEvent-> owner staff bootstrapStudentRegisteredEvent-> exam-result backfill
External process boundary
Treat workers and third-party HTTP APIs as outbound integrations:
exam->grading-svc,report-svc, RabbitMQ, MinIOai->chatbot-svcbilling-> 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:
JwtAuthenticationFilterandInternalHmacAuthFilterpopulate tenant and user context before controller code runs.RlsTenantContextAspectbindsapp.tenant_idat@Transactionalentry.TenantAwareTaskDecoratorConfigcopies 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:classuses the Java packagecom.lumie.classroom.- The docs route Staff Service still points at the
modules/staffGradle subproject. modules/activity-logexists on disk but is not included insettings.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
@Transactionalmethod 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.Txexists 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.javamodules/homepage/src/test/java/com/lumie/homepage/application/service/HomepageQueryServiceTest.javamodules/staff/src/test/java/com/lumie/staff/application/service/StaffCommandServiceTest.java