Multi-tenancy is one of those decisions where the cost of getting it wrong is not felt for about eighteen months, and then it is felt all at once.
You can ship a product for one client without thinking about it at all. The second client works too, if you are willing to copy some things. By the fifth, either the architecture carries it or every new client is a small project — and by then the decisions that would have prevented that are extremely expensive to revisit.
These are the choices that actually matter, roughly in the order you have to make them.
Decision one: how isolated is tenant data?
There are three models and the industry has largely settled on which is right for which situation.
| Model | What it means | Good when | Cost |
|---|---|---|---|
| Shared everything | One database, one schema, a tenant_id column | Most SaaS. Cheapest to run and operate. | One missing filter is a data breach |
| Shared database, separate schema | One instance, a schema per tenant | Stronger separation, moderate client counts | Migrations multiply; connection overhead |
| Separate database | One instance or cluster per tenant | Regulatory or contractual isolation, very large clients | Expensive; operations scale linearly with clients |
Start with shared everything unless you have a written reason not to. Enterprise healthcare and financial clients sometimes contractually require physical separation, and if that is on the table you need to know on day one, because retrofitting it is close to a rewrite.
A hybrid is common and sensible: shared by default, with the option to move a specific client onto dedicated infrastructure without changing the application code. That is only possible if the tenant identifier is threaded through everything from the start.
Decision two: make it impossible to forget the tenant filter
In a shared model, exactly one class of bug matters more than all the others: a query that forgets its tenant filter and returns another client's data.
Code review will not reliably prevent this. Someone will write a quick query at 6pm and it will look fine.
The answer is to remove the opportunity. Callers should not be able to construct an unscoped query at all.
// The tenant is captured once, at the boundary, and closed over.
// There is no function here that can be called without it.
function repositories(tenantId) {
const scope = { pk: `TENANT#${tenantId}` };
return {
members: {
byId: (id) => db.get({ ...scope, sk: `MEMBER#${id}` }),
list: (q) => db.query({ ...scope, ...q }),
},
claims: {
byMember: (id) => db.query({ ...scope, skPrefix: `CLAIM#${id}#` }),
},
};
}
// Handler
const repo = repositories(auth.tenantId); // from the verified token, never the request body
const member = await repo.members.byId(memberId);In a relational database the equivalent is row-level security, so the isolation is enforced by the engine rather than by developer discipline. Either way the principle is the same:
Tenant isolation should be a property of the system, not a rule people remember.
Derive it from the verified session token or from the hostname resolved at the edge. Never from a request body, a query parameter, or a header the client can set. If a user can change their tenant by editing a request, you do not have tenancy — you have a suggestion.
Decision three: configuration instead of forks
This is the decision that determines whether client number ten costs an afternoon or a sprint.
Clients will want different things. Different branding, different onboarding steps, different fields, different modules, different document templates, different rules about what an admin may do. The question is where that variation lives.
if (tenant === 'acme') { ... }Names the client in code. Every new client is a code change, a review, a deploy, and a risk to the other nine.
if (config.enrolment.requiresEmployeeId) { ... }Names the capability. New clients are rows in a table.
In practice you want a layered configuration: a platform default, an optional per-tenant override, and validation on write so a bad configuration is rejected in the admin screen rather than discovered by a user.
const config = {
...platformDefaults,
...tenantOverrides[tenantId],
};The discipline is knowing when not to configure. Every setting is a permanent branch in behaviour that has to be tested. If only one client will ever use it and it is genuinely peculiar to them, a configured extension point is better than a flag — or the honest answer is that this request does not belong in the product.
Decision four: onboarding must not require an engineer
Write this test down and use it as your definition of done:
Can someone who is not a developer create a new tenant, brand it, choose its modules, invite its admin, and have a working product — with no deploy?
If yes, you have a multi-tenant platform. If no, you have a product that supports several clients by hand, and the difference will show up in your delivery capacity within a year.
What that requires: a tenant creation flow, asset upload, theme configuration with validation, module toggles, a seeded admin user, and a way to preview the result before going live.
Decision five: think about blast radius before you need to
Shared infrastructure means shared failure. A bad deploy affects everyone. One client's bulk import can consume capacity everyone else needs. One client's data volume can slow queries for the rest.
Deploy progressively. Ship to one internal tenant, then a small client, then everyone. A canary tenant catches more than a staging environment does, because it has real data.
Isolate background work. Batch jobs and imports belong on a queue with bounded parallelism, not competing with user requests for the same capacity.
Rate limit per tenant. One client's integration looping should degrade that client, not the platform.
Be able to disable one tenant. Occasionally the right emergency action is to pause a single client's jobs rather than take everything down.
Decision six: make every signal tenant-aware
The first question in any incident is “who is affected?” If your logs, metrics and error events do not carry the tenant, you cannot answer it, and you will spend the first thirty minutes of every incident establishing scope instead of cause.
logger.error('enrolment_failed', {
tenantId, // always
traceId,
memberId,
step: 'eligibility-check',
vendor: 'acme-eligibility',
durationMs,
});With that field present, “is this everyone or one client?” is a single query — and as established earlier, that one answer eliminates most of the possible causes immediately.
Decision seven: plan for per-tenant data lifecycle
Unglamorous, and it will be in a contract eventually. You need to be able to export everything belonging to one client, and delete everything belonging to one client, without touching anyone else.
If tenancy is threaded properly through your data model, both are straightforward. If it is not, the day a client leaves becomes a manual archaeology project — and if they are in a regulated industry, a slow one with legal attention on it.
The decisions ranked by how expensive they are to change
| Decision | Cost to reverse later |
|---|---|
| Isolation model | Very high — close to a rewrite |
| Tenant in the data access layer | High — touches every query |
| Configuration vs code branching | High — every accumulated conditional must be unwound |
| Tenant-aware observability | Low — add the field and move on |
| Self-service onboarding | Low — build it when the pain justifies it |
Spend your early design time on the top three. The bottom two can wait until you have clients asking, and they will not punish you for waiting.
The test that tells you it is working
Adding your next client should be an afternoon of configuration, not a sprint of engineering.
When that is true, the architecture is doing its job — and the team gets to spend its time on the product instead of on variations of it.