Architecture
Multi-Tenancy Without Tenant Plumbing Everywhere
An architectural approach to introducing tenant isolation without manually threading tenantId through every layer of an application.
Notes on centralized tenant identification, EF Core global query filters, and explicit tenant context at HTTP, Service Bus, and Timer Trigger boundaries.
- Topic
- PublishedArchitecture
- Published
- Sep 2026
- Read time
- 9 min
- Signals
- Tenant isolationGlobal query filteringHTTP boundaryService Bus boundaryTimer Trigger boundary
Setting
Context
A system that was built for a single tenant has to be extended to support multiple tenants. The naive way is to add a tenantId column to every table and pass it through every method, every service call, and every background job. The implementation cost of that approach is significant, and it is paid over and over as the system grows.
The work described here is a different shape: an architectural approach that keeps tenant isolation as a single, central concern, instead of a cross-cutting one scattered through every layer.
What we are trying to avoid
The naive approach, and why it is expensive
The naive approach is to add tenantId as a parameter to every repository method, every service method, and every background worker. Every read becomes 'read WHERE tenantId = ?'. Every write becomes 'write WHERE tenantId = ?'. Every forgotten check is a potential cross-tenant leak.
- Every layer needs to know about tenantId.
- Every developer needs to remember to filter on it.
- Every code review has to check that it was remembered.
- Every background job needs to receive tenantId from somewhere - usually an awkward somewhere.
One place to ask
Centralised tenant identification
The first decision is where the tenant is identified. The principle: there is exactly one place in the application that decides who the current tenant is, and every other layer either inherits that answer or refuses to operate without it.
- A single tenant provider resolves the tenant for the current execution context.
- Every layer that needs the tenant asks the provider, instead of receiving it as a parameter.
- Layers that should not have to think about the tenant never receive a tenantId at all.
- Layers that need it (e.g. the data access layer) ask the provider implicitly through a filter.
Where filtering actually lives
EF Core global query filters
The data access layer is the right place for tenant filtering to live, because every read goes through it. In EF Core, global query filters attach a tenant predicate to every entity query, automatically. Application code does not need to remember to add a WHERE clause.
The model is conceptually simple: every tenant-owned entity has a global filter 'tenantId = currentTenant'. New entity types only need the filter applied at model configuration time. After that, no application code has to think about tenant filtering for reads.
- Reads cannot accidentally cross tenants, because the filter is attached to the model.
- Writes need their own discipline, because the filter does not constrain them.
- Reporting queries that legitimately span tenants explicitly opt out of the filter.
- Cross-tenant operations live in dedicated paths that bypass the provider.
Where the tenant enters the system
HTTP request boundary
For HTTP requests, the tenant is identified once - from the host, the route, a header, the authenticated principal - and the result is stored on the tenant provider for the lifetime of that request. Application code inside the request never has to ask.
Where implicit inheritance stops
Service Bus boundary
Service Bus messages do not inherit tenant context. A message handler has no HTTP request to read the tenant out of, and there is no implicit threading to lean on. This is the first boundary where the system has to be explicit about the tenant: every Service Bus message carries tenantId as a property, and every handler reads it before doing work.
- Tenant is a first-class property of every cross-process message.
- Handlers set up the tenant provider from the message property, not from ambient state.
- Missing tenantId on a message is a contract violation, not a fallback.
- Cross-tenant messages are explicitly marked and routed through a separate path.
Scheduled work without a request
Timer Trigger boundary
Timer-triggered functions are the second boundary where implicit inheritance stops. A scheduled job does not have a request and does not have a message - it has a clock. If the job is tenant-scoped, it has to identify the tenants explicitly: query them, iterate over them, and re-establish tenant context for each one before doing work.
- The timer job discovers the set of tenants to operate on from its own data.
- For each tenant, the job sets up the tenant provider and runs the work.
- Tenant context is re-established for each iteration - never inherited from a previous one.
- Cross-tenant aggregation happens outside the tenant-scoped code path.
Outcome
What this buys
With central identification and global filtering, most application code does not have to think about tenancy. The places that do have to think about it - the provider, the global filters, the message contracts, the timer jobs - are stable and small.
- Application code does not pass tenantId around.
- Data access code does not add tenant filters by hand.
- Code review does not have to look for missing WHERE clauses.
- Background processing pays the explicit-contract cost once, at the boundary.
An estimate of the cross-cutting implementation effort avoided is substantial - estimated to be in the order of most of the repeated code changes a tenantId-everywhere approach would have required. The figure is an estimate, not a measured metric.
Related notes
More engineering notes
- Distributed Systems
Debugging Distributed Failures
A working note on tracing failures in distributed order and payment workflows, identifying the actual failure point, and the discipline of investigating before changing architecture.
- Performance
Diagnosing Database Performance Problems
Working notes on investigating database performance against large relational datasets, including high-CPU queries, indexes, N+1 patterns, projections, and caching.
Related case study
