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.

Features ship dark

There is one codebase and no per-client fork. Each client reaches the system on its own subdomain, and the subdomain picks the database. When one client asks for a feature, I build it once, in the shared code. It ships to every server on the next update switched off, and is switched on per client. A feature for one business becomes something every business can turn on later, never a branch only one of them has. The other half of not forking is saying no: I weigh each request against the product and turn down the ones that would bend it around a single client.

Schema as state, not history

Clients update when they ask to, not on a release calendar, so at any moment their databases sit on different versions. Replaying migrations in order would make a client six updates behind replay all six, correctly, on live data. Instead I treat the schema as state: a template database holds the shape every client should have, and the update tool compares a client's live database with it and generates only the difference. Six updates behind converges in one step. Every apply runs the same way — back up, compare, I read the generated SQL, test it on a copy, apply to one client first, log it per client — and larger changes carry a dated runbook with a baseline and rollback notes. The tool is AI-assisted, so it has hard guardrails: hooks block destructive SQL aimed at a client database. The tool proposes; I own what reaches production.

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.