📕 Read the full ebook The Multi-Tenant Engineer · 36pages
Your browser cannot display PDFs inline. Open in new tab
This post is a condensed rewrite. The PDF is the full book.

This piece is for the solo developer who has just taken on a second customer, or is about to. By the end you will have the principle for keeping one customer’s data from leaking into another’s: the boundary lives in the structure of the system, not in the memory of the person writing queries. The principle has three parts. Where the boundary sits, who enforces it, and how you make forgetting it hard.

Here is the conclusion up front. Most multi-tenant incidents are not attacks. They begin in ordinary Friday-evening deploys: a query that forgot one WHERE clause, a cache key with no tenant, a nightly job that emails every user in the system. So the real goal of multi-tenant design is not performance. It is making that class of mistake structurally hard.

A concrete scene first. One server holds three tenants: a two-person bakery, a six-person design studio, a fifty-person agency. All three log into the same site, run the same code, write to the same disk. The bakery must never see the studio’s client contracts, and the agency’s heavy overnight job must not slow down the bakery’s pages. Those two sentences are the whole system. This essay is about the structure that keeps them from breaking by accident.

Illustration of the core idea of Multi-Tenant Isolation Is a Structure Problem, Not a Habit Problem A visual metaphor for the article’s key idea.

A tenant is a boundary, not a user

In a multi-tenant system, a tenant is the unit that means a customer. It is the set of users who share one application instance but must never see each other’s data. One company, one tenant. The design studio is a tenant; the two-person bakery next door is a tenant.

The distinction that matters: a tenant is a boundary, not a person. Inside the boundary, sharing is normal. The studio’s founder and her team lead reading the same document is routine operation. Across the boundary, not a single row should be visible. The bakery reading the studio’s client contracts is not permitted; if it can happen, it is an incident waiting to be deployed.

This is why ‘we will partition data by user account’ is a wrong start. An account is a unit that moves inside a boundary; it is not the boundary. Whoever is logged in, the system must first resolve which tenant that user belongs to, and only then may that user see data within that tenant. A system with the order reversed passes the same error to every later design decision.

Almost every decision in multi-tenant architecture reduces to one question: where does the boundary sit, and who enforces it, and how? The rest of this essay answers that question in order.

Sharing is a time problem, not a taste problem

A solo developer’s biggest asset is time, and the biggest liability is the same thing. You cannot run thirty database instances. You cannot operate thirty backup jobs or answer thirty upgrade tickets. One server means one deploy, one backup, one security patch. Ten thousand customers does not change that arithmetic.

So as customers arrive, the natural instinct is one infrastructure, shared. That is multi-tenancy, and for a solo developer moving toward SaaS it is effectively the only realistic path. Buy one more server per customer and operations collapse before revenue grows.

The opposite end, over-isolation, collapses at the same speed, in a different shape. A dedicated database per customer is comfortable at five customers. At fifty, the migration script runs all night. At two hundred, it simply stops. Migrations multiply by N, backups multiply by N, credentials multiply by N, and when one instance dies, one customer dies with it.

Isolation is also a one-way street, and that affects the choice. Splitting data before it is mixed is dramatically easier than splitting it after. Once rows are interleaved, an honest separation means tracing back who owned each row, and that tracing depends on the hearsay that was only ever conveyed to you. Hearsay is like code except it is never tested.

So the starting point is the moment the second paying customer appears. A contract that demands data separation, a sentence that mentions an SLA, are the same signal. If, on the other hand, your product will genuinely serve one customer forever, do not build multi-tenancy at all. A tenant column on a product that may never have a second customer is a cost that never pays back.

The typical incident is a Friday deploy

Look at how multi-tenant incidents actually start and the pattern is boring. A query that forgot one WHERE clause. A search API that never learned what a tenant is. A cache key with no tenant. An admin endpoint whose default view is every tenant. A nightly email job that goes out to every user in the system. None of them required an attacker. All of them shipped in an ordinary deploy on an ordinary evening.

The cost of a leak does not stop at one bug. A customer buys features, but they also hand you data. The moment customer A’s data reaches customer B is a contract violation, not a defect to file.

And the story travels. It is not one relationship that breaks. Once the fact that your data might be visible to your neighbor on this platform is known, every customer carries the same anxiety at the same time. The patch lands in hours. Trust takes months to come back, and sometimes it does not.

So the design cannot take human memory as its premise. People forget filters; a security model built on memory and character does not hold. There is one question the design must answer: how do you build a structure where a forgotten filter breaks one query and stops there?

Half-done isolation is worse than none

Multi-tenancy fails in three shapes: no isolation, over-isolation, and half-done isolation. Over-isolation was covered above. No isolation is also hard to diagnose, because when the second customer’s request arrives and nothing changes, the data is mixed wholesale. The common shape, and the dangerous one, is the third.

Half-done means the database is partitioned by tenant but the cache is not. The queue message carries no tenant. The file storage path has none either. In such a system the database can be perfectly clean while the search results show a neighbor’s file name. A value written under tenant A’s key comes back inside tenant B’s response. A worker whose message lacks a tenant has no way to know which database to open.

Half is worse than none because none is legible. When no boundary exists anywhere, you know the boundary is missing and can fix everything in one deliberate pass. With half, you carry the belief that you are safe. And the place that leaks is exactly the part you forgot. List the forgotten parts: cache, queue, file, search, batch, admin screen.

The underlying rule is simple. Every channel that data passes through needs a boundary. Channels are always more numerous than you think. When you design tables, the database itself is what comes to mind, but the places data actually passes through run on to cache, queue, file storage, search index, batch jobs, and admin paths. Listing those channels one line at a time is half of isolation design.

The boundary must exist in every data channel

Walk the channels. The cache key carries the tenant, as a prefix or as a tenant-scoped namespace. Only when the tenant is in the key first can you later treat a heavy tenant evicting everyone else’s entries with a capacity cap.

The queue message carries the tenant id, and the worker sets its context before touching any data. Without it, a worker cannot know which database to open. File storage paths get a tenant prefix, and access is granted by checking the tenant rather than by trusting the file id.

The search index sits inside the tenant’s scope, or is built so that every query is filtered by tenant at the engine layer. It is safer for no query, from anyone, to leave the tenant’s scope. The admin path is separate from the user path entirely. A screen that shows all tenants is an audited privilege, not a shortcut in the default view.

Batch jobs and cron are the classic blind spot. They never pass through request middleware. A job that sends a report to every user each night must loop per tenant and set context in each iteration. If the request path has boundaries and the job path does not, the job path is where it leaks.

Finally, the tenant must always come from authenticated identity. Never from the request body or a query parameter. A user who may state their own tenant id may equally state someone else’s. A value received from outside cannot be a boundary you can trust.

Make the filter the default, the backstop the database, the test the memory

Three actors enforce the boundary. First, the application layer. When a request starts, middleware resolves which tenant it belongs to and puts that in the request context. The query library or ORM then attaches the tenant filter to every query in that context automatically. The point is to make the filter the default. If it has to be written by hand, people will skip it.

Second, the database layer. PostgreSQL offers RLS, row-level security. You set a policy on each table: a row is visible only when the table’s tenant column matches a session variable, and the application sets that variable when it connects. RLS does not replace application scoping. It is a backstop. A bug that bypasses the ORM still lands on a connection where RLS applies. A connection that failed to set the session variable sees nothing, and that is the safe direction to fail.

Third, the operational layer, the part most people forget. Batch jobs, cron, admin tooling, backup scripts. These paths do not go through request middleware, so they must carry the tenant context themselves. A design that only guards the request path guards how customers read data and leaves how you move it unguarded.

Then add one test that pays for itself: the cross-tenant property test. Create two tenants, drive a batch of randomized operations against one, and assert that the other observes nothing. It runs in CI on every deploy. You cannot remember that every query is correct; the test remembers. A forgotten filter, a cache miss, a job looping in the wrong order all become a red build instead of a customer’s email.

Where the boundary sits: from one column to one database

Last, the physical question: where does the boundary sit in the storage layer? It is not a binary choice between a tenant column and separate databases. It is a spectrum from fully shared to fully separate. All tenants in one database, every row tagged with a tenant id, is the first level. A separate table namespace per tenant is the second. A separate database per tenant inside one instance is the third. A separate instance per tenant, sometimes a separate server, is the fourth.

Higher levels buy stronger isolation and add one thing to manage per tenant. A solo developer normally starts at the first level and promotes only the tenants that need the third. The first level is sufficient while tenants are small and homogeneous, no customer carries regulatory requirements, and total data volume stays moderate. The discipline is to stay at the first level while it is sufficient.

Write the promotion criteria before you need them: a contract that demands separation, a tenant whose data crosses a threshold, a tenant that becomes a noisy neighbor the limits cannot contain, a customer who asks for the stronger form and pays for it. In practice promotion is one-way. Moving a tenant back from a dedicated database to the shared one is a data-merging project, and you rarely want that. So the question at promotion time is not whether you can do it. It is whether you want to run this tenant permanently at a higher cost.

  Tenant column (level 1) Dedicated database (level 3)
Isolation strength App scoping + RLS Structural separation
Units to manage One database One per tenant
Deletion Row-by-row stop Drop the database
Heavy-tenant handling Hard Easy
Initial complexity Low Moderate

For both levels to coexist, the data-access layer needs one clean seam: a repository interface that takes the tenant context and hides whether the query goes to a shared table or a dedicated database. With the seam, the sentence your data lives in its own database is a flag change and a price line, which is a product you can sell. Without it, tenant checks scatter across handlers, and every new feature is a coin flip.

The endpoint is unglamorous. Onboarding a new customer is one row in a registry, one limit on a meter, one line in a ledger. No rewrite; one line. And at that moment the bakery still cannot read the studio’s contracts. That is where multi-tenant design arrives: the boundary living in the structure of the system, not in the head of the developer on call. The isolation levels, the quota numbers, the load limits, and the pricing structure that sits on top of all of it are collected in the accompanying ebook, The Multi-Tenant Engineer.

Tags: data-security, database-design, isolation, multi-tenancy, rls, saas, solo-founder

Categories:

Updated: