2023 — presentSenior Software Engineer · Classera
Multi-Tenant Education & Commerce Platform
One codebase, many organisations, millions of users
- users across tenant organisations
- Millions
- production stability
- 99.8%
- data isolation and customisation
- Per-tenant
The problem
A single platform had to serve many independent organisations — schools, districts and storefronts — each expecting its own data, branding and rules. Tenancy handled in application code is one forgotten WHERE clause away from a data leak, and a shared request path means one busy tenant degrades everyone else.
The approach
Multi-tenancy pushed into the schema rather than defended in the service layer, with PostgreSQL and TypeORM enforcing isolation, and an event-driven backbone that keeps slow work off the request path. Kafka carries high-throughput streams, RabbitMQ carries work that must not be lost, and Redis handles caching and pub/sub fan-out.
Architecture
- Angular (v10+) front ends with NgRx for global state and RxJS for reactive data flows, served across tenant-specific configurations.
- NestJS services behind a single ingress; every request resolves its tenant at the boundary, and nothing downstream reads tenant identity from user input.
- PostgreSQL with TypeORM as the system of record — isolation is a schema property, so no code path can bypass it.
- Apache Kafka for high-throughput event streaming between services; RabbitMQ for asynchronous work that needs delivery guarantees.
- Redis for read-through caching and pub/sub, backing real-time dashboards and notifications alongside WebSockets and Firebase Realtime Database.
Tenancy belongs in the schema
The tempting version enforces tenancy in a service-layer guard. That works until a batch job, a support script or a new endpoint writes to the same table without going through it. Making the database enforce isolation means the failure mode is a rejected query rather than a silent cross-tenant read — and it stays true for every code path written after I leave.
Kafka and RabbitMQ do different jobs
They are frequently treated as interchangeable. Kafka is a durable, replayable log: right for analytics streams and anything you may need to reprocess. RabbitMQ is a broker with per-message acknowledgement and dead-lettering: right for a single unit of work that must happen once and must not be lost. Using each for what it is good at removed a whole class of arguments about delivery semantics.
Real-time without a second architecture
Dashboards and notifications ride on Redis pub/sub and WebSockets rather than a separate real-time stack. Services publish the same domain events they already emit; the socket layer is a subscriber like any other. That keeps one source of truth for what happened, instead of a live path and a persisted path that drift apart.
Stability came from tests, not heroics
99.8% production stability is a consequence of Jest unit coverage and Cypress end-to-end suites running in GitLab CI on every merge, not of careful deploys. When the pipeline is the gate, releases get boring — which is the point.