Skip to main content
Architecture

Multi-tenant architecture from day one

Day one does not mean building for a thousand customers before you have two. It means a small number of decisions that cost almost nothing while the database is empty and are brutally expensive once it is not.

What "day one" is actually asking for

The advice to build multi-tenant from the start is usually heard as a demand to over-engineer, and resisted for good reason. That is not what it means. It means a handful of decisions — principally that every row knows which customer it belongs to, and that no code path can read a row without saying which customer it is acting for — that are free to make before there is data and cost a quarter to retrofit afterwards. Everything else on this list can wait. Those two cannot, because they are properties of the data, and data is the one thing you cannot refactor over a weekend.

The failure we get called about looks the same each time. A product was built for one customer, or built with the tenant identifier present on some tables and inferred on others. A second customer arrives and the seams hold. Somewhere around the fifth, a query joins two tables where the constraint was only applied to one, and someone sees a row that belongs to another company. That incident is not primarily a technical problem. It is a disclosure you have to describe to a customer in writing, and the conversation that follows is about whether you can be trusted with their data at all.

So the goal is not perfect isolation on day one. It is that the isolation you have is structural rather than remembered — enforced by the shape of the schema and the query path, not by every engineer recalling to add a clause.

You are probably here because

  • You are about to sign customer number two and the schema was written for customer number one
  • One large customer’s nightly job makes the application slow for everyone else
  • A customer asked you to delete or export all of their data and nobody knows how long that takes
  • Somebody found a query that was missing its tenant constraint and now nobody trusts the rest

The isolation-model and context-propagation sections address the first and last. Limits and cost attribution cover the second. The third is the section on export, deletion and single-tenant restore.

Three isolation models, and the money is in the middle

Every design lands on one of three, or on a hybrid of two. The tradeoff is the same in each direction: stronger isolation costs more per customer and makes cross-tenant work harder, while weaker isolation is cheap and efficient and puts the burden on your query discipline.

ModelFitsWhat it costsWhere it hurts
Shared schema
One database, tenant column on every table
Many customers, similar shapes, cost sensitivityCheapest to run, cheapest to operate, one migrationOne missing predicate is a cross-tenant leak; noisy neighbors are real; per-tenant restore is hard
Schema per tenant
One database, namespace per customer
Tens to low hundreds of customersModerate; migrations must run N timesConnection and catalog pressure at a few hundred; cross-tenant reporting gets awkward
Database per tenant
Separate database or instance
Few, large, isolation-sensitive customersHighest; per-customer infrastructure and operationsCost per tenant, migration fan-out, and a fleet to keep on one version
Hybrid
Shared pool plus dedicated for some
Most products after a couple of yearsTwo paths to maintain, deliberatelyOnly bearable if promotion between them was designed rather than improvised

For most products the honest answer is shared schema with the discipline to make it safe, plus a designed path to move a specific customer onto dedicated infrastructure when they are large enough or contractually require it. What gets teams into trouble is arriving at the hybrid by accident: one customer was moved by hand during a crisis, and now there are two deployment shapes, one of which is undocumented.

What to build before customer number three — our ranking

Tenant identity on every table, non-null
96
A query path that cannot omit the constraint
90
Tenant context carried into jobs, caches and events
82
Per-tenant limits and a way to see who is loudest
66
Single-tenant export and deletion
58
A designed promotion path to dedicated infrastructure
34

Urgency as we rank it for an early product. The top three are nearly free now and expensive later; the bottom two can wait for a real customer to ask.

Tenant identity is a data-model decision, not a middleware one

Put a tenant column on every table that holds customer data, make it non-null, and index it as the leading column of the composite keys you query by. Not "on the tables where it seems relevant" — on all of them, including the join tables, the attachments, the audit rows, the queued items, and the tables you expect to be small forever. The tables people skip are exactly the ones that end up in a join that leaks.

Then make the constraint hard to omit. There are two workable approaches and they are not exclusive. The database can enforce it: most relational engines support a row-level policy that applies a predicate to every query based on a session variable, so a query written without the clause returns nothing rather than everything. Or the application can enforce it: a single data-access layer that requires a tenant context to construct a query at all, with a lint or review rule preventing raw queries outside it. The database approach is stronger because it also covers the ad-hoc query somebody runs at three in the morning during an incident.

Choose the identifier deliberately too. Use an opaque, non-guessable identifier rather than a sequential integer, and never derive tenancy from anything a client sends in a request body. Tenancy comes from the authenticated session, always, on the server.

Isolation you have to remember is not isolation. The only kind that survives contact with a growing team is the kind where forgetting produces zero rows instead of somebody else's.

Where tenant context gets lost

Request handlers are the easy part, because that is where everybody is looking. Context escapes in five other places, and each has produced a real incident somewhere.

Background jobs. A job enqueued with a record identifier and no tenant runs with whatever context the worker happens to have. Put the tenant in the job payload, and have the worker establish context before any handler code runs.

Caches. A cache key of user_settings_42 is a cross-tenant leak the moment two tenants have a user 42, and it is the fastest-moving kind because it serves the wrong data at full speed. Every cache key gets the tenant in it, without exception; the discipline is easier if the cache client itself takes the tenant and constructs the key.

Connection pools and thread reuse. If you set a session variable to establish tenancy, it must be reset when the connection returns to the pool. A leftover setting on a reused connection is the classic ten-in-the-morning-on-a-Tuesday bug: rare, load-dependent, and terrifying.

Event consumers and webhooks. Messages outlive the request that produced them. Tenancy travels in the message, and consumers validate it rather than trusting the topic.

Search indexes and derived stores. Anything you copy data into needs the same discipline as the source. A search index without tenant filtering built into every query is a place where one query returns the whole customer base, and it is often built by a different person than the one who built the database access layer.

Architecture Note

Keep noisy-neighbor work out of the shared path entirely

The most common performance complaint in shared-schema products is that one customer's bulk import or nightly report makes the application slow for everyone. Rate limiting helps, but the structural fix is separation of workload rather than of data: put bulk and analytical work on its own queue with its own worker pool and its own database replica, so the worst a heavy tenant can do is fill their own lane. Then set per-tenant concurrency caps on that lane. A single global queue means the largest customer sets the latency for the smallest, and no amount of tuning changes that.

Send the schema and we will tell you where the isolation is remembered rather than enforced.

Email your table list, how tenancy is expressed today, and roughly how many customers you have to contact@precisionfederal.com. You get back a short written note naming the tables and paths we would fix first and why. One business day. No charge, no meeting, no deck.

contact@precisionfederal.com

Migrations are where per-tenant models get expensive

In a shared schema, a migration is one operation and the hard part is doing it without downtime on a large table. In schema-per-tenant or database-per-tenant, a migration is N operations, and N is a number that grows while you sleep. At a hundred tenants a five-minute migration is a workday. At a thousand it is a project with a status page.

If you take a per-tenant model, build the fan-out tooling on the day you take it, not when it hurts. That means running migrations in parallel with a bounded worker count, tracking per-tenant version state so a partial run can resume, and treating "some tenants are on version 41 and some on 42" as a normal state that the application must tolerate for hours. That last point is the one people miss: application code has to work against both schema versions for the duration, which means the same expand-then-contract discipline as a zero-downtime change on a single database, applied across a fleet.

Whichever model you take, keep the version state queryable. "Which tenants are not yet on the current version" should be one query, not an inference from log files.

Per-tenant limits, configuration and the fork you must not take

Limits belong in data rather than in code. Requests per minute, storage, records, seats, concurrent jobs, file size — each as a value on the tenant with a default, so raising a limit for one customer is a row update rather than a deploy. Give support a screen for it. Otherwise every limit change becomes an engineering ticket, and limits stop being adjusted, which means they start being circumvented.

Configuration is the same principle with a sharper edge. Customers will ask for behavior differences: a required field, a different notification schedule, a custom status name. Express those as configuration read at runtime, never as code branching on a tenant identifier. The moment you write if tenant == "acme" you have started a fork that will be invisible in six months and load-bearing in twelve. If a request genuinely cannot be expressed as configuration, that is a product decision to make on purpose, with a price attached.

The related discipline is feature flags scoped by tenant, which is how you ship a change to one customer safely and how you roll a risky migration forward gradually. Flags need an expiry date and an owner, or they become a second configuration system that nobody understands.

Cost attribution, before someone asks about margin

In a shared system, per-customer cost is invisible by default, and the first time anyone asks is usually when a pricing conversation is already underway. Instrument early: requests, storage, compute seconds, queue time, and whatever the expensive resource is in your particular product. It does not need to be perfectly accurate. It needs to be good enough to rank customers by cost and to notice when one moves.

The finding is nearly always the same shape and it is worth having ahead of the conversation rather than during it: a small number of customers account for a large share of variable cost, and the distribution rarely matches the distribution of revenue. Knowing that changes how you price, which limits you set, and which customer you would actually be happy to move onto dedicated infrastructure at their expense.

Cost to change once there is real customer data

Adding tenancy to a single-tenant schema
97
Switching isolation models with customers live
88
Retrofitting single-tenant restore into shared backups
72
Untangling per-tenant branches in application code
64
Adding per-tenant limits held as data
26
Adding per-tenant cost instrumentation
18

Difficulty as we rank it, driven by how much live data each change has to move. Decide the top two before the data exists.

Export, deletion, and the restore nobody tested

Three operations get promised in contracts and discovered in a hurry. Build them while they are small.

Export. Everything belonging to one customer, in an open format, produced by a repeatable job rather than by an engineer with a query editor. Run it quarterly on your largest tenant so you know how long it takes and whether it still covers the tables added last month.

Deletion. Genuine deletion, covering the database, the object storage, the search index, the caches, the analytics store and the backups within their retention window. Write down the retention answer honestly, because "we deleted it except in backups for thirty-five days" is a fine answer and a discovered surprise is not. Deletion should be a job with a receipt, not a person running statements.

Single-tenant restore. This is the one shared-schema products consistently cannot do, and it is the one customers actually need. A customer bulk-imports badly at ten in the morning and wants their state from nine thirty back; a full-database restore would roll back everyone else. The workable answers are per-tenant logical backups on a schedule, or an append-only change history that lets you reconstruct a tenant's state at a point in time. Both cost something. Neither can be improvised on the day, and the day comes.

Promoting one customer to dedicated infrastructure

At some point a customer is large enough, loud enough, or contractually particular enough to warrant their own database or their own deployment. Handled well, this is a scheduled migration using tooling you already have. Handled badly, it is a fork.

Design it as a routine operation: the same code and the same schema version, with the tenant's data moved and a routing layer that decides, per tenant, which database to talk to. Keep the deployment identical — if the dedicated instance runs different code, you now maintain two products and one of them has a single customer. And decide the criteria before anyone asks: a size threshold, a cost threshold, a contractual requirement, and a price. Making that decision in the middle of a negotiation guarantees you make it badly.

Testing isolation, which is a different job from testing features

Isolation bugs do not show up in ordinary tests, because ordinary tests use one tenant. Build a test fixture with at least two tenants holding data with colliding identifiers — the same user number, the same record name, the same external reference — and then assert the negatives across the whole surface: every list endpoint, every detail endpoint, every export, every search, every report.

Two more checks are worth automating. Add one that fails when a new table is created without a tenant column, which catches the migration written at speed. And in a non-production environment, run a query auditor that flags any statement against a tenant-scoped table without a tenant predicate. Both are small pieces of work and both catch the exact defect class that produces the disclosure conversation.

What goes wrong, specifically

  • Tenancy on most tables but not all, so the leak arrives through a join table nobody thought about
  • Cache keys without a tenant, serving the wrong customer's data at full speed
  • Jobs enqueued with a record id and no tenant, running with whatever context the worker had
  • Session variables not reset on pooled connections, producing a rare, load-dependent leak
  • Per-tenant branches in code rather than configuration in data
  • One shared queue, so the largest customer sets everyone's latency
  • No single-tenant restore, discovered on the morning a customer needs one
  • A dedicated deployment created during a crisis, running code nobody else runs

A four-week pass, whether you are starting or repairing

Tenancy Pass

1
Inventory every table, cache, index and queue. Mark which carry tenancy today and which infer it
Days 1–3
2
Choose the isolation model on customer count, isolation requirements and cost. Write the reasoning down
Days 4–5
3
Add tenancy everywhere it is missing and enforce it in the query path or the database
Week 2
4
Fix context propagation: job payloads, cache keys, pooled connections, consumers, search
Week 3
5
Two-tenant test fixture with colliding identifiers. Negative assertions across every endpoint
Week 4
6
Limits as data, per-tenant cost instrumentation, and the export, deletion and restore jobs
Week 4+

Step five is the one that tells you whether the previous four worked. Colliding identifiers matter more than they sound: most isolation bugs are invisible when tenant A has records 1 to 100 and tenant B has 101 to 200, and become obvious the moment both have a record numbered 42.

Most isolation bugs hide behind non-overlapping test data. Give two tenants the same record number and the same user name, and the ones that matter surface in an afternoon.

Before you sign the second customer

  • Every table holding customer data has a non-null tenant column, indexed as a leading key
  • The query path cannot omit the tenant constraint, enforced by the database or a single access layer
  • Tenancy comes from the authenticated session, never from a request body
  • Job payloads, cache keys, events and search queries all carry the tenant
  • Pooled connections reset any tenant session state on release
  • Bulk and analytical work runs on a separate lane with per-tenant concurrency caps
  • Limits and configuration are data, and no code branches on a tenant identifier
  • Export and deletion are repeatable jobs, tested against the largest tenant
  • A single tenant can be restored to a point in time without affecting the others
  • A two-tenant fixture with colliding identifiers runs against every endpoint in CI

Bottom line

Multi-tenancy is not a feature you add; it is a property the data either has or does not. Get tenant identity onto every table and make the constraint structural, carry context through the places that outlive a request, and keep customer differences in configuration rather than code. Those decisions cost almost nothing before there is data. Everything else on this page — limits, cost attribution, single-tenant restore, dedicated promotion — is real work you can schedule when a customer makes it urgent. The distinction is worth holding onto, because it is what separates a sensible day-one investment from the over-engineering people are right to resist.

Frequently asked questions

Should a new product start with a shared schema or a database per customer?

Shared schema for most products, with the isolation enforced structurally rather than by convention, plus a designed path to move a specific customer onto dedicated infrastructure later. Database-per-tenant makes sense when customers are few, large, and require separation contractually, and when you can afford migration fan-out across a fleet. The expensive mistake is arriving at a hybrid by accident during a crisis.

What is the actual cost of adding tenancy later?

For a working product with live customers, expect a quarter, most of it spent on the query paths rather than the schema change. Adding the column is a day. Finding every query, job, cache, index and report that assumed a single customer, and proving none was missed, is what takes the time — and it has to be done with production traffic running, which is why doing it while the database is empty is close to free by comparison.

How do you stop one large customer from slowing everyone down?

Separate the workload rather than only rate limiting the requests. Bulk imports, exports and analytical queries go on their own queue with their own workers and their own read replica, with per-tenant concurrency caps on that lane. With a single shared queue the largest customer sets the latency everyone else experiences, and no tuning changes that. Add per-tenant cost and latency instrumentation so you can see who is loudest before customers tell you.

Can a shared-schema system restore one customer's data?

Not from an ordinary full-database backup, which is exactly why this gets discovered on a bad morning. You need either per-tenant logical backups on a schedule or an append-only change history that allows reconstructing one tenant's state at a point in time. Both cost storage and engineering. Decide which one you are doing before a customer bulk-imports the wrong file and asks for the previous half hour back.

How do you handle customers who want different behavior?

As configuration read at runtime, held as data on the tenant, with sensible defaults — never as code that branches on a tenant identifier. A single conditional on a customer name is invisible in six months and load-bearing in twelve, and it multiplies. If a request genuinely cannot be expressed as configuration, treat it as a product decision with a price rather than a small favor in a pull request.

1 business day response

Adding tenancy to something that already has customers?

Send the table list, how tenancy is expressed today and your customer count. Our engineers will come back with where isolation is remembered rather than enforced, and the order we would fix it in — or take the migration as a scoped piece of work. Email bo@precisionfederal.com.

Email an engineerCapabilitiesMore insights →
ArchitectureData SystemsPlatform EngineeringScaling