Chapter 22 · Global Query Filters, Multi-Tenancy, Soft Delete, and Data Partitioning
Single Database, Schema-per-Tenant, and Database-per-Tenant Tradeoffs with DbContext Configuration
Compare discriminator-column, schema-per-tenant, and database-per-tenant designs by EF support, migrations, pooling, backup/restore, noisy-neighbor behavior, and operational ownership rather than tenancy slogans.
Learning outcomes
Compare discriminator-column, schema-per-tenant, and database-per-tenant topologies using EF support and operational evidence.
Explain why schema-per-tenant is not directly supported by EF Core and is impossible on SQLite.
Configure a database-per-tenant context without mutating one live context’s connection target.
Reason about migration ownership, connection pools, backup/restore, noisy neighbors, and tenant movement.
Choose a topology from requirements for isolation, scale, operations, and provider support—not a generic maturity ladder.
Keep the free mandatory lab reproducible with SQLite files while clearly separating server-provider behavior.
1. Tenancy topology is an operational architecture decision
Global filters solve one important problem: default row scoping inside a shared model. They do not answer whether every tenant should share the same physical database. ServiceHub now needs an explicit topology contract because backup/restore, maintenance windows, noisy-neighbor risk, connection pools, migrations, and incident blast radius differ more than the LINQ surface suggests.
2. Three common shapes and EF Core support
| Topology | EF Core support | Isolation/operations | ServiceHub use |
|---|---|---|---|
| One database + TenantId discriminator | Direct via global filters | Lowest operational overhead; shared blast radius and capacity | Mandatory free lab |
| Schema per tenant | Not directly supported by EF Core | Many schemas/migration targets; model-shape/cache complexity | Discuss/avoid by default |
| Database per tenant | Direct via per-tenant configuration/connection string | Strong backup/restore and blast-radius isolation; many DBs/pools/migrations | Free SQLite-file lab + server variant |
EF’s own multi-tenancy guidance explicitly marks schema-per-tenant as not supported. SQLite has no schemas at all, which makes the limitation even more concrete for the course baseline.
3. Shared database: simple model, hard security discipline
In the discriminator approach, all work-order rows share one table and every tenant-aware key/index/query must include tenant semantics where required. The database has one migration history and one backup unit. This is operationally efficient but tenant-specific restore is an application/data-recovery exercise rather than a simple database restore.
SELECT tenant_id, COUNT(*)FROM work_ordersWHERE is_deleted = 0GROUP BY tenant_id;-- One table, one file/database, multiple tenants.
4. Schema per tenant: why “just change ToTable schema” is fragile
Changing
ToTable("work_orders", TenantSchema) changes the EF
model shape. EF normally caches one model per context type, so
dynamic schemas imply model-cache-key customization, separate
migration targeting, and potentially unbounded cached models.
More importantly, EF Core’s multi-tenancy documentation does not
directly support this topology. Do not introduce it just to
avoid a tenant column.
protected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.Entity<WorkOrder>() .ToTable("work_orders", TenantSchema); // model shape now varies}
SQLite cannot reproduce schema-per-tenant because SQLite has no schemas. SQL Server/PostgreSQL can have schemas, but provider capability does not make EF migration/model lifecycle simple.
5. Database per tenant: choose the database before creating the context
public sealed class TenantDbContextFactory( ITenantCatalog catalog, ILoggerFactory logging){ public ServiceHubContext Create(string authorizedTenantId) { var target = catalog.ResolveRequired(authorizedTenantId); var options = new DbContextOptionsBuilder<ServiceHubContext>() .UseSqlite(target.ConnectionString) .UseLoggerFactory(logging) .Options; return new ServiceHubContext(options, new FixedTenantContext(authorizedTenantId)); }}
Do not create a shared context and call
Database.SetConnectionString while it is in use.
Contexts are short-lived units of work; resolve the authorized
target first and construct the context for that target.
6. Connection pools and database fan-out
On server providers, connection pools are typically partitioned
by connection string. A database-per-tenant estate can therefore
create many pools and server sessions, especially when each
tenant gets a distinct database/catalog in the connection
string. Capacity planning must include number of active tenants,
pool limits, failover behavior, credentials, and idle
connections. DbContext pooling is a separate
mechanism and does not merge driver connection pools.
| Concern | Shared DB | DB per tenant |
|---|---|---|
| Migrations | One coordinated target | N targets; rollout/health state per DB |
| Backup/restore | Whole shared DB; tenant restore is selective | Natural per-tenant backup/restore unit |
| Noisy neighbor | Shared CPU/I/O/log/locks | Better isolation, still shared server if co-hosted |
| Connection pools | Usually fewer pool keys | Potentially many pools/credentials |
| Tenant move | Data copy/partition operation | Move/restore database and update catalog |
7. Deliberate failure: connection string from untrusted tenant input
var tenant = request.Query["tenant"];var cs = $"Data Source=servicehub-{tenant}.db";var options = new DbContextOptionsBuilder<ServiceHubContext>() .UseSqlite(cs) .Options;
Even with sanitized filenames, this lets the request choose a data partition. The repair is an authorization-derived tenant identity plus a server-owned tenant catalog mapping identity to a known connection target. Treat the catalog as security-critical configuration and audit changes to it.
8. Migrations are part of the topology contract
A shared database has one migration history. Database-per-tenant requires a fleet migration strategy: inventory target versions, migrate one coordinated owner, canary a subset, observe, continue, and record failures. Do not run migrations concurrently from every application instance. Schema-per-tenant multiplies this complexity further and is one reason it is not the default EF tenancy pattern.
tenant-a servicehub-a.db schemaVersion=2026082701 status=readytenant-b servicehub-b.db schemaVersion=2026082701 status=readytenant-c servicehub-c.db schemaVersion=2026082604 status=blocked-review
9. Mandatory free lab: compare shared-file and per-tenant-file SQLite
-
Create
shared.dbcontaining tenants A/B and apply the global filters from Lessons 1–2. -
Create
tenant-a.dbandtenant-b.dbwith identical schema but only one tenant each. - Record file sizes, migration histories, and backup/restore steps for both shapes.
- Demonstrate that a tenant-specific restore is trivial for the per-tenant file and a selective data operation for the shared file.
- Record that schema-per-tenant is not reproducible on SQLite and is not directly supported by EF Core.
- Delete all disposable files after the lab.
10. Production judgment and bridge
Choose a shared discriminator database when operational simplicity and shared infrastructure dominate and the application/database defenses are strong. Choose database-per-tenant when isolation, tenant-specific restore, regulatory boundaries, or independently scalable storage justify the fleet-management cost. Do not choose schema-per-tenant merely because the engine supports schemas; EF model/migration lifecycle matters. Lesson 4 turns from physical topology to the subtler runtime risk: cached models and pooled contexts carrying tenant-sensitive state.
Check your understanding
- Which multi-tenancy topologies does EF Core directly support?
- Why is schema-per-tenant problematic in EF Core?
- Why must database target selection happen before creating a context?
- How does database-per-tenant affect connection pooling?
- Which topology makes per-tenant restore simplest?
- Why is SQLite useful in this topology lab?
Review the answers
1. Discriminator-column tenancy via global query filters and database-per-tenant via configuration/connection selection.
2. It changes model shape per tenant, complicates model caching/migrations, and EF documentation marks it as not directly supported.
3. A DbContext is a unit of work bound to its configured provider/connection; mutating shared live context configuration risks cross-tenant use and thread-safety failures.
4. Distinct connection strings can create distinct driver pools, increasing pool/session capacity requirements.
5. Database per tenant, because the database itself is the recovery boundary.
6. Separate files model database-per-tenant cheaply and repeatably, while also proving SQLite has no schema-per-tenant feature.
Authoritative references
- EF Core multi-tenancy — official support table for discriminator, schema, and database-per-tenant patterns
- DbContext configuration — context construction/lifetime and connection configuration
- Advanced performance topics — DbContext pooling versus driver connection pooling
- Dynamic models — model caching when model shape varies
- EF Core migrations — migration lifecycle that multiplies across tenant databases
- SQLite limitations — no schemas/sequences and provider DDL boundaries