Skip to main content

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

PathWhat it anchors
lumie-frontend/app/api/[...path]/route.tsSame-origin /api/... proxying, upstream header cleanup, and localhost cookie rewriting
lumie-backend/app/src/main/java/com/lumie/app/LumieApplication.javaSingle Spring Boot entrypoint for the backend deployable
lumie-backend/app/build.gradle.ktsOne backend app module wiring the shared libs plus all business modules into one jar
lumie-backend/settings.gradle.ktsThe checked-in backend module inventory
lumie-backend/libs/common/src/main/java/com/lumie/common/domain/TenantScopedEntity.javaTenant-scoped entity contract and tenant_id population
lumie-backend/app/src/main/java/com/lumie/app/config/RuntimeDbRoleGuard.javaBoot-time refusal to run with a PostgreSQL role that can bypass RLS
lumie-backend/app/src/test/java/com/lumie/app/migration/MigrationsRlsIntegrationTest.javaCross-tenant isolation test proving the RLS model on a restricted runtime role
lumie-worker/services/{analysis,grading,report,chatbot}/pyproject.tomlThe independent FastAPI worker runtimes and their per-service dependency shape
.github/tilt-up.shLocal-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 under lumie-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

  1. A browser loads pages from the Next.js frontend.
  2. Browser-side application calls use same-origin /api/v1/... routes, and the Next.js proxy forwards them to the configured backend API origin.
  3. The backend authenticates the user, resolves tenant context from the request, and dispatches the call into the appropriate module inside the monolith.
  4. Inside the active transaction, the backend binds app.tenant_id so PostgreSQL RLS only exposes rows for the current tenant.
  5. 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-Cookie for 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 RuntimeDbRoleGuard and in integration tests by MigrationsRlsIntegrationTest.
  • Worker services remain separate FastAPI deployables instead of being folded into the backend runtime.

Where To Go Next