Selected engineering
02The architecture

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.

Architecture, schema, migration, deployment20+ tenant databases · 200 tables eachEF Core 8 · MySQL 8 · LinuxIn production
Administration / Tenants20+ live
Tenant databases
DatabaseOutletsSizeSchema
TNT-004Northgate Retail94.2 GBv148
TNT-007Al Manara Stores31.1 GBv148
TNT-011Harbour Superstore149.8 GBv148
TNT-015Cedar Mart2760 MBv147
TNT-019Palm Grocers52.3 GBv148
15+ more, one database each
No shared rows200 tables eachorg → company → outlet

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.

One applicationASP.NET Core 8 · EF Core 8 · one codebaseTenant Aown databaseORG → COMPANY→ OUTLETTenant Bown databaseORG → COMPANY→ OUTLETSuperstorehighest volumeORG → COMPANY→ OUTLET… 20+one per clientNO SHARED ROWSNO TENANT FILTER TO FORGET — THE CONNECTION IS ALREADY SCOPED
A missing tenant filter can only ever read the client's own data, because the connection is already scoped to their database.

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.