System design template

Multi-tenant SaaS architecture.

One database serving every customer, and exactly one bug standing between that and showing one customer another customer's data.

Download for macOS
v0.1.33 · .dmg · Apple Silicon & Intel
Signed & notarized by Apple · opens without a Gatekeeper warning
sha256 698955a0187bc039f4c74f5d05a9f10fbb27376a45788a0a241d1326b73873c7
Download for Windows instead
$curl -fsSL https://lucidtrain.com/install.sh | sh
Multi-tenant SaaS architecture. 12 components across 5 tiers.
Multi-tenant SaaS architecture. Rendered by the same ELK layout engine the app runs: the agent emits components, tiers and edges, and the engine places them, so the boxes cannot overlap.

The components

Every row below is read from the graph that produced the diagram above, so the two cannot disagree.

ComponentTierWhy it is there
Web appClientSupporting component
API gatewayEdgeSupporting component
Auth serviceApplicationTenant id is resolved here, once
Tenant contextApplicationMiddleware, so no query can forget the filter
Application APIApplicationSupporting component
Billing & plansApplicationSupporting component
Background jobsApplicationQueue partitioned per tenant, so one cannot starve the rest
Admin consoleApplicationSupporting component
PostgresDataRow-level security, tenant id on every table
RedisDataKeys namespaced per tenant
Object storeDataPrefix per tenant
Billing providerExternalSupporting component

Design decisions worth arguing about

A diagram shows what was chosen. It does not show what it cost, and that is usually the part that matters in a review or an interview.

Shared database with row-level security

A database per tenant gives the strongest isolation and turns every migration into an operation across hundreds of databases. A shared schema with a tenant column is far easier to operate and puts one predicate between customers. Enforcing that predicate in the database with row-level security rather than in application code is what makes it a guarantee rather than a convention, because a forgotten WHERE clause then returns nothing instead of everything.

Tenant context in middleware, resolved once

Reading the tenant from the request in every handler means every new endpoint is an opportunity to forget. Resolving it once at the edge of the request and making it ambient means queries cannot be written without it. The cost is a piece of implicit context, which is unfashionable and is exactly the right shape for something that must never be omitted.

Partition background work per tenant

A single shared job queue lets one tenant's bulk import consume every worker and stall everyone else, and it will, usually on the day they onboard. Partitioning by tenant with per-tenant concurrency limits contains it. It costs scheduling complexity and some idle capacity, which is much cheaper than the alternative of every customer being affected by the largest one.

Noisy neighbours are a product problem

Technical isolation limits the damage but does not decide what should happen when a tenant exceeds their share. Per-plan quotas make that explicit and enforceable, and they turn an operational incident into a billing conversation. Without them the only available responses are to absorb the cost or to intervene manually.

How it changes with scale

Cost is dominated by the largest tenants while revenue is spread across all of them, so per-tenant resource accounting matters earlier than expected. Most tenants are small, which is what makes sharing efficient; a handful are large enough that moving them to dedicated infrastructure is both possible and often the right answer.

Where it breaks first

A missing tenant predicate in a query written outside the normal path, such as a reporting job or a data migration. Row-level security catches it if the job connects as a normal role; jobs that connect with elevated privileges bypass exactly the protection that makes the design safe, which is why the admin path deserves more scrutiny than the application path.

Draw this yourself

Open the Diagram tab and describe the system. The agent emits a semantic graph rather than coordinates, so you can edit the components and the layout re-solves instead of drifting.

shell
$ Diagram a multi-tenant SaaS application: auth resolving tenant identity, tenant context middleware, application API, Postgres with row-level security, namespaced Redis, per-tenant object storage prefixes, per-tenant partitioned background jobs, billing and an admin console.

When the shape is right, Implement in code turns the canvas into a markdown specification, every component, every relationship and the notes, and starts a real turn in the Code tab with it.

FAQ

Questions about this design

When should a tenant get their own database?

When they are large enough that their load affects others, or when a contract requires physical isolation. Designing so that a tenant can be moved later, without changing application code, is more valuable than choosing correctly up front.

How should tenants be identified?

From the authenticated session, never from a request parameter. A tenant id the client can set is an access control bug waiting for someone to notice it.

Do tenants need separate encryption keys?

Only if the threat model or a contract requires it. Per-tenant keys complicate every operation touching data at rest, including backups and restores, and that cost is real.

How do you test tenant isolation?

With a test that runs every query as one tenant and asserts another tenant's rows are invisible, run in CI rather than reviewed by eye. Isolation is exactly the kind of property that holds until a refactor quietly breaks it.

More templates