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.

ModelWhat it meansGood whenCost
Shared everythingOne database, one schema, a tenant_id columnMost SaaS. Cheapest to run and operate.One missing filter is a data breach
Shared database, separate schemaOne instance, a schema per tenantStronger separation, moderate client countsMigrations multiply; connection overhead
Separate databaseOne instance or cluster per tenantRegulatory or contractual isolation, very large clientsExpensive; 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.

js
// 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.

Where the tenant comes from

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.

Avoidif (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.

Doif (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.

js
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.

01

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.

02

Isolate background work. Batch jobs and imports belong on a queue with bounded parallelism, not competing with user requests for the same capacity.

03

Rate limit per tenant. One client's integration looping should degrade that client, not the platform.

04

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.

js
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

DecisionCost to reverse later
Isolation modelVery high — close to a rewrite
Tenant in the data access layerHigh — touches every query
Configuration vs code branchingHigh — every accumulated conditional must be unwound
Tenant-aware observabilityLow — add the field and move on
Self-service onboardingLow — 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.