Skip to content
Back to Writing

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

Related case study