Engineering Journal · ARCHITECTURE · June 4, 2024 · 17 min read
The Tenant Is Not a Column You Remember to Add
Shared tables and a tenant_id will carry a product a long way. They will not carry a leak, a noisy neighbour, or a restore you cannot explain.
By Golam Sorwar, Tech Lead and Full Stack Engineer in Dublin.
I first met multi-tenancy as a convenience. One codebase, many clients, a column that said whose row this was. Dropshipping storefronts, a school product, later operational platforms where "tenant" might mean an institution, a campus, or a brand. The convenience is real. So is the day you realise the column is a reminder, not a wall.
I am not going to walk through packages or recommend a folder structure. I want the trade-off: how much isolation you buy, what you pay to operate it, and why "we will remember to scope the query" is not a security model.
If two customers share a process, they will eventually share a bug. The architecture decides how far that bug can walk.
Context
The products were Laravel and MySQL, sometimes Redis for cache and queues, one deployable. Clients expected their data to be theirs. They did not all expect a private database. Early on, neither did we. Shared schema is how a small team ships a marketplace or a school system without becoming a hosting company.
The pressure arrives in pieces. A report that should not see another school's students. A job that forgot the tenant and emailed the wrong list. A customer who wants a restore that is not everyone else's restore. A noisy tenant whose catalogue search makes the shared primary unhappy.
None of those is theoretical. They are the ordinary weather of this shape.
I also include reporting as a first-class pressure. A platform that needs a cross-tenant view — usage, billing, support — will fight hard isolation. A customer who needs a legal export of only themselves will fight hard sharing. You cannot satisfy both with a slogan. You have to say which one you are building this year.
The problem
The apparent problem is how to store many customers in one app.
The real problem is blast radius. Who can read. Who can write. What happens when a query is wrong. What happens when a migration is slow. What happens when you need to give one customer their data and not a neighbour's. Tenancy is those questions wearing a schema.
A useful way to say it: tenancy is not a feature you add to an app that already works. It is a property of every place work enters and every place data leaves. HTTP is one entrance. The queue is another. The scheduler is a third. A report export is an exit. A support impersonation tool is both. If you only designed the first, you designed a lobby and left the loading dock unlocked.
The tempting solution
Add tenant_id, a global scope, middleware that sets the current tenant from the host or the token. Feel finished. Most tutorials end here because the demo only has one happy request.
If you are ambitious, you sprinkle tenant_id on every table "just in case," including tables that are actually global, and then you spend a year explaining why a country list is per client.
Why that is not enough
Request middleware does not run in the console, in a queue, in a webhook, in a broadcast callback, unless you make it. The leak I worry about is not the clever attacker first. It is the forgotten job.
Global scopes are helpful and treacherous. They hide the predicate. A new query that uses a raw join or a different connection will not hear the sermon. Tests that do not include a second tenant will not either.
Shared hardware means noisy neighbours. One tenant's import is everyone's Saturday. Isolation of data is not isolation of load.
Backups and legal stories diverge. "We take a dump of production" is not an answer to "give us our data as of Thursday." If you promised surgical restore, you bought a different architecture than you drew.
There is a quieter leak: metadata. Logs, exception trackers, search, a support tool that stores the last request. If those systems do not know tenancy, you will paste a payload into a ticket and have shown one customer another customer's life. Isolation that stops at Eloquent is costume isolation.
Options
Shared schema, tenant_id, hard scopes, adversarial tests. Advantage: one migration, one report path, operable by a small team. Disadvantage: a class of bugs that are catastrophic rather than local. Risk: you rely on culture. Culture is not a control.
Shared database, separate schemas per tenant. Advantage: slightly clearer isolation, still one server. Disadvantage: migrations become a fleet problem. You will write a runner. The runner will be a product.
Database per tenant. Advantage: restore, noisy neighbour, and some compliance conversations get easier. Disadvantage: connection management, schema drift, cost, and a platform you must actually run. I have seen teams jump here for prestige and drown in plumbing.
Separate application per tenant. Advantage: true isolation. Disadvantage: you no longer have a multi-tenant product. You have a services company.
I start shared, with hostility toward implicit context, and I move a tenant to stronger isolation only when a concrete force — restore, load, contract — pays for the platform.
There is a fifth option people reach for under panic: a query parameter that "the admin can pass to see another tenant." That is impersonation. Impersonation is a product with an audit. It is not a debug flag you leave in the report controller.
Trade-offs
Shared is cheaper to evolve and more expensive to get wrong. Isolated is more expensive to evolve and cheaper to explain after an incident.
Reporting wants shared. A single query across tenants is how a vendor operates. Isolation wants you to build an export warehouse. That warehouse is often the real second system.
You should not trade away a fail-closed default. Unknown tenant means no data, not "first tenant we found." A missing context in a job is a crash I will take. A guessed context is a leak I will not.
Decision
For the products I have been responsible for, shared schema was the honest start. The decision that mattered was not the column. It was that tenant context had to be explicit in every non-HTTP entry point, and that a query without a tenant was a bug we could write a test for.
I would not pick database-per-tenant because it sounds mature. I would pick it when a customer or a load profile makes shared restore or shared CPU indefensible.
Hybrid is allowed. One noisy or regulated tenant on their own database, the rest shared, is an operational shape. It is also a complexity tax. If you take it, write the rules for migrations and reporting or you will grow a special case that only one person can deploy.
Implementation / Thinking process
Resolve the tenant at the edge. Bind it once. Pass it into jobs as data, not as a leftover singleton you hope is still there. I have lost time to workers that remembered the previous tenant. That is a horror story you can prevent with boring parameters.
Use scopes, yes, and also use tests that create two tenants and try to read across. If your suite never has a second tenant, you do not have tenancy tests. You have happy-path tests with extra paint.
Cache keys include the tenant. Queue names sometimes should. Rate limits almost always should. Otherwise you have isolation in MySQL and a commune in Redis.
Think about the admin path. A platform operator who can see all tenants is a different role than a tenant admin. If you reuse the same gates, you will one day show the wrong school in a typeahead.
Search, mail, and files are where tenancy goes to die. A global index, a shared bucket with guessable keys, a mail job that loads recipients without a tenant predicate — the relational scope will not save you. If the artefact leaves MySQL, it needs the tenant in its name or in its query, or it is public with extra steps.
Tests should try to be hostile. Create tenant A, act as tenant B, assert empty. Do it for HTTP, for a job, and for a report. If any of those is missing, that is the next leak.
Failure modes
The unique index that forgot the tenant. Two clients cannot create "ADMIN" or "DEFAULT." You "fix" it by suffixing, which is a smell that the identity was never tenant-local.
A search index or a Scout driver that is global. The database was scoped. The search was not.
A report "for us" that runs without context because it runs on a schedule. Schedulers are tenants too, or they are superusers. Pick one and write it down.
A migration that locks a hot shared table. Isolation of rows does not isolate DDL.
The helpful typeahead that searches people globally because the developer was building support tools. Support tools are the most dangerous clients you have. They are staffed by people you trust, pointed at every tenant, and rarely covered by the same tests as the customer path.
Operational consequences
Support will ask you to "just look at tenant X." Your tools will grow a tenant switcher. That switcher is production infrastructure. If it is a raw database change in a query window, you will eventually run the query in the wrong place.
As you grow, someone will request an exception: this customer needs their own database. If you have no story for a hybrid fleet, you will either refuse a real need or invent a one-off that becomes the new architecture by accident.
Billing and support will also ask for cross-tenant search. That search is a privileged product. If you build it as "the same query without the scope," you have implemented the leak on purpose. Build it as a separate capability with an audit, or do not build it.
Lessons
Tenancy is an isolation budget. Spend it on the failures you cannot tolerate. Do not spend it on a diagram.
A column is a field. A boundary is a property of every way work enters the system. Only one of those is multi-tenancy.
What I would do differently today
I would have written the two-tenant test before the first global scope. Scopes make you feel safe. Tests make you safer.
I would have treated Redis keys as tenanted from day one. I used to fix that after a surprise. The surprise is always the same: a cache is a second database with worse manners.
I would have written the restore story before the sales story. "We can give you your data" is easy to say in a shared dump. It is a different product if it must be theirs and only theirs, as of a day, without a week of engineering. If you cannot keep that sentence, do not offer it. Offering it is how shared-schema teams inherit an isolation architecture they never budgeted.
Closing thought
You can run many clients on one schema for a long time if you are willing to make leakage hard and to say no to restore stories you cannot keep. What you cannot do is remember the tenant. Memory is not an isolation primitive.