Skip to content
Kumar Chandrachooda
.NET

Multi-Tenancy Retrospective: What I'd Do Differently

Nine parts of architecture posts lie by omission unless the tenth is the ledger. What earned its keep — isolation as data, MaxTenants = 1, the schema-keyed model cache — and the honest list of shortcuts: plaintext connection strings, cache windows, reflection, and a fan-out loop that still says TODO.

By Kumar Chandrachooda 14 Mar 2026 6 min read
The keep pile, the rebuild pile

Every architecture series lies by omission. For nine parts I have shown you the platform's best angles — with footnoted confessions where the code fell short — and a series like this earns its credibility in exactly one way: by ending with the full ledger. Here it is. What I would keep unchanged, what I would rebuild, and what the whole exercise taught me about multi-tenancy in .NET.

The keep pile

Isolation as a property of the tenant. The single best decision, made before any code: TenantType is data on the tenant row, not architecture baked into queries. Every capability that followed — hosting a compliance-sensitive tenant on its own database next to fifty small ones packed in a group, from one codebase — flows from that one modelling choice. If you take one idea from this series into your own platform, take this one.

MaxTenants = 1. Modelling a dedicated database as “a database group that is full at one” (part 5) collapsed two isolation strategies into one abstraction. Capacity accounting, health checks, purge — written once. I keep waiting to regret the cuteness; three chapters of lifecycle code later, I have not.

Per-schema __EFMigrationsHistory plus the schema-keyed model cache. The pair from parts 3 and 4 — MigrationsHistoryTable("__EFMigrationsHistory", schema) and InStoreCacheKey — is the technical core of the platform, and it is right. Rollouts are idempotent and resumable by construction; every tenant knows its own migration state. I would re-implement both tomorrow, internal-API risk and all.

The two-step delete. “You cannot purge what is not already soft-deleted” (part 7) has a compile-time-guarantee quality that policy documents never achieve. The interlock is one if statement, and it is the cheapest insurance in the codebase.

End-to-end verification over unit tests for migrations. The PowerShell script that provisions real tenants against real PostgreSQL and polls for Active caught the model-cache bug that no mock would ever have surfaced. For infrastructure code, the test that talks to the infrastructure is the only one that counts.

The rebuild pile

Now the other column, roughly in order of how much each one bothers me.

Connection strings are stored in plaintext. The TenantConnection XML comment says “encrypted”; the column says otherwise. The comment is a wish. The fix is not even exotic — keep a reference in the control plane and resolve the secret from a vault (Key Vault, Secrets Manager, pick one) at connection-provider level, where the caching already lives. This is the gap I would close first, before any feature work.

The caches have no invalidation story. Part 2's resolver caches for five minutes, connections for ten. Suspend a tenant and they keep working until the TTL runs out (part 7 had to confess this too). Redis is already in the docker-compose file and is currently used for approximately nothing important. Publish a tenant-status-changed message, evict on receipt, and suspension becomes what the admin console pretends it is: immediate.

The connection provider never grew up. TenantConnectionProvider still resolves from IConfiguration with a naming convention — the “this would typically query the tenant management database” comment marks the seam where the real implementation belongs. All the data it needs exists (part 6's TenantConnection); the lookup was simply never wired. It is the most consequential unfinished seam in the codebase, because until it closes, the control plane's careful records are decorative for the data plane.

Reflection in two load-bearing places. Route-value tenant resolution reflects into HttpContext.Request to avoid a framework reference; the entity-configuration factory reflects over generic base types to inject the schema, and carries an angry TODO acknowledging it. Both work; both are the kind of code that fails at runtime on an upgrade that compiles clean. The route resolver should take the framework dependency and read RouteValues directly. The configuration factory should be replaced by an explicit, boring list — five lines per entity, zero surprises. I said it in part 4 and it bears repeating as a rule: magic is a loan, and reflection charges variable interest.

Two provisioning services exist. Shared.Infrastructure has a TenantProvisioningService; so does TenantManagement.Application, and only the latter is real. The former is an evolutionary leftover — the fossil record of a refactor that moved provisioning into the control plane and never deleted the origin. Dead twins like this cost nothing at runtime and a great deal in onboarding, when a new engineer confidently modifies the wrong one.

The fan-out loop is still a log statement. Part 8's honest gap remains the largest one: ApplyMigrationsForAllTenantSchemasAsync migrates public and then logs where the fleet loop would go. The primitives beneath it are finished and tested; the conductor is not. Rebuilding it properly means a triggered job — enumerate groups, enumerate schemas per group, apply with bounded concurrency, write TenantMigrationHistory rows — and deliberately not a startup hosted service, for the deploy-time-coupling reasons argued in part 8.

EnsureCreated still lurks. A few code paths (EnsureDatabaseCreatedForSchemaAsync, the odd controller endpoint) call EnsureCreatedAsync, which builds the current model without writing migration history — a database it touches can never be migrated forward cleanly. It coexists with MigrateAsync in the same service, which is one bad support call away from a very confusing afternoon. MigrateAsync everywhere; delete the other path.

Suppressed warnings need their compensating tests documented. PendingModelChangesWarning is silenced for structural reasons (part 4), which is defensible only while the end-to-end test exists and runs in CI. The suppression and the test are a matched pair, and nothing in the code says so. Pairing them in a comment is a five-minute fix for a failure mode measured in days.

What the exercise actually taught me

Three lessons that outlast this codebase.

Multi-tenancy is a control-plane problem wearing a data-plane costume. I started this project to fight EF Core internals, and parts 3 and 4 were genuinely the hard engineering. But every operational capability — suspend, restore, capacity, rollout visibility — was decided by the boring entities of part 6. The exotic code made the platform possible; the boring code made it runnable.

Every guarantee must be implemented once per tool. EF got schema-awareness through the model; Dapper needed it separately through search_path discipline (part 9's fine print). Resolution enforced tenancy in middleware; endpoints needed RequireActiveTenant anyway. Whenever a stack has two of anything, the tenancy invariant must be proven twice, and the second proof is the one that gets forgotten.

Trust boundaries belong in writing, not in code review memory. The tenant-schema header (part 2) and the migration-with-connection-string endpoint (part 8) are both safe given a deployment assumption that no compiler checks. The codebase's DECISIONS.md — decisions extracted into one reviewable document — turned out to be worth more than half the unit tests; the deployment assumptions deserve the same treatment.

Would I run this design in production? The isolation model, the migration machinery, the lifecycle — yes, with the rebuild pile above as the hardening backlog, plaintext secrets first. Which is, I suspect, the most honest closing sentence an architecture series can have: the design survived contact with its own retrospective, and the TODO list is written down where everyone can see it.

Thanks for reading the series. If you missed the beginning, part 1 — three ways to isolate a tenant — is where the map starts.