Multi-tenancy where one bug cannot leak a competitor's data
One codebase, one physical database per client. A missing tenant filter can only ever read that client's own data, because the connection is already scoped to it.
| Database | Outlets | Size | Schema |
|---|---|---|---|
| TNT-004Northgate Retail | 9 | 4.2 GB | v148 |
| TNT-007Al Manara Stores | 3 | 1.1 GB | v148 |
| TNT-011Harbour Superstore | 14 | 9.8 GB | v148 |
| TNT-015Cedar Mart | 2 | 760 MB | v147 |
| TNT-019Palm Grocers | 5 | 2.3 GB | v148 |
| … 15+ more, one database each | |||
Interface rendered in code from the platform’s own structure — illustrative, not a screenshot. Tenant names are invented; no client is identifiable.
The problem
The clients are retailers, and some of them compete with each other. If every client's data sits in one shared database, then one missing line in one query shows one shop its rival's prices. That is the bug I did not want to be possible. It is also the bug that is hardest to catch, because on a test database with a single client every query looks correct.
Twenty-plus businesses on one codebase, each with a three-level organisation → company → outlet hierarchy inside their own data, enforced at the repository layer rather than left to whoever writes the next query.
What I built
The isolation model, the schema, the migration tooling and the deployment procedure. I also own the consequences of it, which is the more interesting half.
I isolated tenants at the database level, not the row level. No shared rows and no tenant filter for anyone to forget. The argument is safety, not scale: I wanted the class of bug that leaks data between competitors to be impossible to write, rather than merely tested for. The org hierarchy inside each database is enforced in the repository layer, so the same guarantee holds one level down without every caller having to remember it.
The cost I took on knowingly
Every schema migration runs twenty-plus times instead of once. That is the real price of this design and I own it: migrations run backup-first, per tenant, with a dry-run report and written rollback notes, and one tenant always goes first. Under roughly fifty tenants this is the right call for financial data. Above a few hundred, the migration cost wins and I would move to row-level isolation enforced at a layer nobody can bypass — the decision is a function of tenant count, not a principle.
What it produced
- 20+ isolated tenant databases in production, 200 tables each, 16 bounded contexts.
- 85 controllers, 78 repositories behind 90 interfaces, and 95 application services on a strictly layered path: HTTP → Controller → Service → Repository → EF Core.
What I would do next
I have since rebuilt authorisation properly — three composable permission profiles over 63 feature flags, anything unclassified resolving to *deny*, and three checks that stop the application booting if an endpoint is left unguarded. What I would change is the order: designing that in at endpoint fifty would have cost a fraction of what retrofitting it across 818 did.
Ten minutes on a screen-share tells you more than this page can. Get in touch.