Architecture
Multi-Tenancy
Introducing tenant isolation into an existing system without scattering conditionals across the codebase.
A design exercise in taking a system that was not built for multi-tenancy and making tenancy a first-class architectural concern. The work focused on the boundaries between HTTP requests, application code, and background processing - and on reducing the repetitive, error-prone code changes that tenant isolation would otherwise require.
- Read
- 9 min
- Signals
- Tenant isolationGlobal query filteringCentralised tenant identificationCross-cutting concernsHTTP / app / background boundaries
Contribution & scope
This case describes tenant-handling decisions and implementation considerations in an existing platform. It presents the system context and approach without attributing every architectural decision or platform change to me alone.
Setting & overview
Context
The system had been built for a single tenant. As the product expanded, multi-tenancy had to be introduced into an existing codebase that already spanned HTTP APIs, background jobs, and reporting queries.
The existing tables already contained a tenant identifier. The data layer was ready for the boundary; the application layer was not. The risk was not 'can we add a tenant column' - it was ensuring that every existing read and write respected the new boundary without requiring a rewrite of every feature.
Why this became a problem
The Problem
Multi-tenancy is a cross-cutting concern. If implemented at every call site, it produces repetitive, error-prone code. If implemented unevenly, it produces data leaks. The task was to find a way to make the boundary invisible at the right layers and explicit at the right points.
Without centralised handling, every layer that touched data had to know about the tenant. Tenant identification would have to be carried manually through each step of a request, repeated in every method signature, and remembered by every developer writing business code. The places that would have needed to know about the tenant included:
- HTTP requests, where the tenant has to be identified from the request context.
- Functions and methods - public and private - that operate on tenant-scoped data.
- Utility and common methods that shared logic between features.
- DTOs, models, and parameters passed between layers.
- Caching methods that had to be keyed by tenant to avoid cross-tenant hits.
- Repositories, which would have to apply tenant filters per call.
- Unit of Work operations, which would have to know the tenant for commit and read consistency.
In practice, this meant developers were manually carrying tenant context from the request into lower layers, repeating the same conditional in many places, and reviewing the same boundary by hand on every feature. The same logic, copy-pasted, was the easiest way to introduce an inconsistency.
The architectural framing
Why This Was a Cross-Cutting Concern
Tenant identification is not really business data that every method should have to receive manually. It is application execution context - the kind of information that should be available implicitly to the code that needs it, and made explicit only at the point where the application receives a new unit of work.
The architectural question, then, was: at which boundary does the tenant enter the application, and how does the rest of the application get access to it without having to ask?
| Layer | Before | After |
|---|---|---|
| HTTP request | Manual extraction, repeated per endpoint | Identified once at the entry boundary |
| Application / domain methods | Tenant passed as a parameter | Resolved through a central provider |
| Repositories and queries | Per-call tenant filter on every read | Applied through a global query filter |
| Caching | Custom keys per cache helper | Tenant included in cache infrastructure |
| Unit of Work | Tenant carried through commit and rollback paths | Resolved from the same context as the queries it surrounds |
| DTOs and models | Tenant fields repeated on every model | Models stay tenant-agnostic; tenant lives in the data layer |
How the chain looks
Architecture / Data Flow
The end-to-end flow has three entry points - an HTTP request, an Azure Service Bus message, and an Azure Timer Trigger - and a single downstream chain that resolves the tenant, exposes it through a provider, and lets the DbContext apply a global filter to every tenant-aware query. The two background-processing entry points are treated as a different boundary from HTTP: the tenant is established explicitly there, not inherited from a request.
What was chosen
Decision
Two architectural decisions carried the rest of the work:
- Centralised tenant identification through a single tenant provider, so every layer reads the same value rather than receiving it as a parameter.
- EF Core global query filters on tenant-aware entities, so the data layer applies the tenant scope consistently without each repository having to remember.
Together, these two decisions moved the cross-cutting concern out of feature code. The DbContext, the provider, and the boundary handlers became the only places that had to know how the tenant is identified; everything else could stay tenant-agnostic.
Where the work had to be explicit
Important Boundaries & Implementation
Most of the application can rely on tenant context being available implicitly. Two boundaries cannot: the HTTP boundary, which has to identify the tenant from the request, and the background-processing boundaries, which have no request to read from at all. The rest of the work concentrated changes in a small number of places - the rest of the application stayed largely unchanged, which was the point of the architecture.
- HTTP boundary - the tenant is identified from the incoming request (route, header, or claim, depending on the application). The resolved tenant is then made available to the rest of the request through the central provider.
- Azure Service Bus boundary - there is no request to read from. The message contract carries the tenant, and the worker handler resolves it before any data access happens.
- Azure Timer Trigger boundary - there is no message and no request. The scheduled job establishes the tenant it operates on (per-tenant, per-partition, or system-wide) before continuing.
- DbContext - wired the tenant provider into the data access layer so that every tenant-aware query is filtered consistently, without the application code above having to remember.
- Tenant provider - the single place the application reads the current tenant from. Every other layer asks the provider, instead of receiving the tenant as a parameter.
- Tenant context - holds the resolved tenant for the duration of a unit of work, so the provider, the DbContext, and the surrounding business code all see the same value.
- EF Core global query filter - the mechanism by which the data layer applies the tenant scope. Once the provider and the DbContext are in place, the filter takes care of the rest.
What was accepted
Trade-offs
- Implicit tenant filtering - queries are scoped at the data layer rather than being explicit at the call site, which makes the boundary invisible by design and concentrates the risk of bypassing it in a small number of places.
- Debugging and query visibility - the generated SQL for tenant-aware queries carries a filter the developer did not write by hand, so the same query produces different results in different tenant contexts. Diagnostics need to surface the resolved tenant, not just the query text.
- Background-job context - every background handler now has to establish the tenant explicitly. The verbosity at the boundary is the price of removing it from the call sites.
- Administrative and system-level operations - any code path that legitimately needs to operate across tenants (reporting, admin tooling, migrations) has to opt out of the global filter explicitly, rather than being filtered by default.
- Risk of bypassing tenant filters - any query that bypasses the DbContext (raw SQL, alternative data access, a future non-EF data source) has to re-establish the tenant scope on its own. The architecture reduces the number of places that need to do this, but does not eliminate the responsibility.
- Additional responsibility in tenant context infrastructure - the provider and the boundary handlers become the only places that have to be correct for the whole system to be correct. Their failure modes are systemic, and they have to be exercised end-to-end.
What changed
Impact & Outcome
Instead of modifying tenant handling throughout the application, the main architectural changes were concentrated around the tenant infrastructure and the application boundaries. Feature code stopped having to know about tenancy at all - the data layer handled it, the provider made it available, and the boundary handlers were the only place where a tenant ever had to be identified or established.
The estimate from the original review of the work - an internal estimate of how much feature-level tenant handling was eliminated - was that the large majority of per-feature tenant plumbing was no longer needed. The figure that has sometimes been associated with this work, around an 80% reduction in development effort, is an estimate, not a measured metric; it should be read as the rough order of magnitude, not a precise result.
- Tenant handling moved out of repositories, DTOs, business methods, and caching helpers.
- The only places that still had to know about the tenant were the boundary handlers, the provider, and the DbContext.
- New features could be written without threading the tenant through the application, which was the practical point of the change.
The architectural outcome was that tenant isolation stopped being a per-feature concern. New endpoints, new background jobs, and new reports all relied on the same boundary-and-provider chain rather than on copy-pasted tenant conditions. The result was a more centralised, more consistent tenant-isolation mechanism, and considerably less cross-cutting implementation in the feature code that came after.
What it taught
Lesson
When a piece of context applies across an application's execution, treating it as centralised execution context can be cleaner than manually threading it through every method. The work was a reminder that cross-cutting concerns are usually not best solved by being explicit at every call site - they are best solved by being explicit at the boundary that introduces them, and invisible everywhere else.
Related work
