Architecture Overview
Lumie is a multi-tenant SaaS education platform for academies. The product is delivered as a web application with a Next.js frontend, a single Spring Boot modular monolith for core business logic, and a small set of independent FastAPI workers for workloads that benefit from their own runtime or scaling model. Tenant isolation is enforced in PostgreSQL with Row Level Security (RLS) in a shared public schema, not with a separate schema per tenant.
Source Anchors
| Path | What it anchors |
|---|---|
lumie-frontend/app/api/[...path]/route.ts | Same-origin /api/... proxying, upstream header cleanup, and localhost cookie rewriting |
lumie-backend/app/src/main/java/com/lumie/app/LumieApplication.java | Single Spring Boot entrypoint for the backend deployable |
lumie-backend/app/build.gradle.kts | One backend app module wiring the shared libs plus all business modules into one jar |
lumie-backend/settings.gradle.kts | The checked-in backend module inventory |
lumie-backend/libs/common/src/main/java/com/lumie/common/domain/TenantScopedEntity.java | Tenant-scoped entity contract and tenant_id population |
lumie-backend/app/src/main/java/com/lumie/app/config/RuntimeDbRoleGuard.java | Boot-time refusal to run with a PostgreSQL role that can bypass RLS |
lumie-backend/app/src/test/java/com/lumie/app/migration/MigrationsRlsIntegrationTest.java | Cross-tenant isolation test proving the RLS model on a restricted runtime role |
lumie-worker/services/{analysis,grading,report,chatbot}/pyproject.toml | The independent FastAPI worker runtimes and their per-service dependency shape |
.github/tilt-up.sh | Local-frontend plus cluster-backed dev split used in day-to-day development |
System Shape
- The frontend is a single Next.js application that serves the user-facing web experience for staff, students, onboarding, and marketing surfaces.
- The backend is one Spring Boot deployable started from
lumie-backend/app. Modules underlumie-backend/modules/*are logical boundaries inside the monolith, not separately deployed services. - Cross-module backend calls are routed through interfaces in
lumie-backend/libs/internal-api, which keeps module boundaries explicit without splitting the application into networked microservices. - Workers under
lumie-worker/services/*are separate FastAPI deployments. They handle specialized workloads such as OMR grading, report generation, analysis, and chatbot orchestration. - Platform services such as PostgreSQL, RabbitMQ, Redis, MinIO, and the observability stack are managed separately from product code in
lumie-infra. - Tenant data is stored in a shared PostgreSQL public schema. Tenant-scoped tables carry
tenant_id, and the runtime database role is intentionally RLS-bound, so Lumie does not rely on schema-per-tenant isolation.
Runtime Request Path
- A browser loads pages from the Next.js frontend.
- Browser-side application calls use same-origin
/api/v1/...routes, and the Next.js proxy forwards them to the configured backend API origin. - The backend authenticates the user, resolves tenant context from the request, and dispatches the call into the appropriate module inside the monolith.
- Inside the active transaction, the backend binds
app.tenant_idso PostgreSQL RLS only exposes rows for the current tenant. - The module reads or writes PostgreSQL data, optionally uses MinIO for file-backed workflows, and returns a response through the frontend back to the browser.
In local development, this path is intentionally split: the Next.js app usually runs on the developer machine, while the backend, workers, and stateful services run in the dev cluster managed through Tilt.
Data and Event Flow
Transactional application data
Most product state lives behind the modular monolith. User actions hit the backend first, and the backend owns the transactional boundary for PostgreSQL reads and writes. This keeps authorization, tenancy, validation, and business rules in one place.
Tenant isolation
Lumie uses PostgreSQL Row Level Security on tenant-scoped tables in the shared public schema. The active tenant is bound per transaction through app.tenant_id, and backend code is written around that assumption. This means the tenant boundary is enforced at the database layer instead of by switching to a tenant-specific schema.
Queue-backed background jobs
Heavy asynchronous workflows use RabbitMQ. For grading and report generation, the backend writes the job row and the event publication record in the same transaction, then forwards the event to RabbitMQ after commit. Worker services consume those messages, perform the background work, and send completion callbacks that the backend consumes to update application state.
Direct worker calls
Not every worker is queue-driven. The AI surfaces also use direct HTTP calls where that fits the interaction model better. For example, the backend proxies chat traffic to chatbot-svc, and the worker calls internal backend endpoints when it needs tenant-safe reads, writes, or conversation persistence.
Deployment Boundaries
- The backend is deployed as one application even though it is developed as many modules. Module boundaries are code-level boundaries, not deployment boundaries.
- Worker services are deployed independently and can be scaled separately from the backend when queue depth or workload shape requires it.
- Infrastructure is provisioned on K3s and reconciled through GitOps in
lumie-infra, with ArgoCD managing the desired cluster state. - Shared platform capabilities such as PostgreSQL, RabbitMQ, Redis, MinIO, secrets management, and observability are part of the infrastructure layer rather than the product repositories.
Verification And Success Signals
These checks stay at the architecture-contract level and do not mutate runtime state:
cd /Users/bluemayne/Projects/Lumie
rg -n "NEXT_PUBLIC_API_BASE|headers.delete\\('origin'\\)|set-cookie" \
'lumie-frontend/app/api/[...path]/route.ts'
rg -n "SpringApplication.run|project\\(\\\":modules:|spring-modulith|springdoc-openapi" \
lumie-backend/app/src/main/java/com/lumie/app/LumieApplication.java \
lumie-backend/app/build.gradle.kts \
lumie-backend/settings.gradle.kts
rg -n "tenant_id|NOBYPASSRLS|app\\.tenant_id|RLS isolation" \
lumie-backend/libs/common/src/main/java/com/lumie/common/domain/TenantScopedEntity.java \
lumie-backend/app/src/main/java/com/lumie/app/config/RuntimeDbRoleGuard.java \
lumie-backend/app/src/test/java/com/lumie/app/migration/MigrationsRlsIntegrationTest.java
rg -n "fastapi==|aio-pika==|httpx==|openai==" \
lumie-worker/services/analysis/pyproject.toml \
lumie-worker/services/grading/pyproject.toml \
lumie-worker/services/report/pyproject.toml \
lumie-worker/services/chatbot/pyproject.toml
Success signals:
- The frontend proxy still strips browser-only headers before forwarding and rewrites
Set-Cookiefor localhost development. - The backend still has one Spring Boot entrypoint and one app module that assembles the module graph into a single deployable.
- The RLS contract is still defended twice: at startup by
RuntimeDbRoleGuardand in integration tests byMigrationsRlsIntegrationTest. - Worker services remain separate FastAPI deployables instead of being folded into the backend runtime.
Where To Go Next
- Tech Stack for the main frameworks and platform choices.
- Backend Overview for the monolith entrypoint and module responsibilities.
- Modules for backend module boundaries.
- Messaging for RabbitMQ-based workflows.
- Multi Tenancy for the RLS tenancy model.
- Frontend Overview for the web application surface.
- Workers Overview for the independent FastAPI services.
- Cluster Overview for the K3s and GitOps layer.
- Data Model Overview for the database structure behind the platform.