Multi-Tenancy¶
Multi-tenancy means multiple customers share the same application and database, but each can only see their own data. In PetFolio, each Account is a tenant - Alice's animals are invisible to Bob, even though they live in the same database tables.
Think of it like a shared office building: every company has their own floor with a locked door. They share the building infrastructure (lifts, reception, electricity), but cannot walk into each other's offices.
This page explains how PetFolio enforces data isolation at the database level.
Why PetFolio needs it¶
PetFolio is a SaaS product where multiple businesses manage their animals through the same API and database. Without multi-tenancy:
- A query for "all animals" would return every animal in the entire system
- A business could accidentally (or intentionally) read another business's data
- Every developer would need to remember to add
WHERE AccountId = ...to every query
Instead, the database automatically filters and stamps data based on the authenticated user's account. Developers write normal queries and the isolation happens transparently.
The interfaces¶
ITenantProvider¶
Provides the current user's account ID. This is the only piece of information the database needs to isolate data.
| Petfolio.Service.Domain/Common/ITenantProvider.cs | |
|---|---|
ITenantProvider is implemented by CurrentUserService (see Claims and Identity), which reads the accountId claim from the ClaimsPrincipal.
IMultiTenant¶
Marks an entity as belonging to a tenant. Any entity that implements this interface is automatically included in tenant isolation.
| Petfolio.Service.Domain/Common/IMultiTenant.cs | |
|---|---|
| Member | Purpose |
|---|---|
AccountId |
The tenant this entity belongs to. Non-nullable - every multi-tenant entity must have an account. |
SetAccountIdInternal |
Called by PetfolioDbContext during SaveChangesAsync to stamp the account ID on new entities. Not intended for direct use in application code. |
Which entities are multi-tenant?¶
| Entity | Multi-tenant? | Why |
|---|---|---|
Animal |
Yes | Animals belong to a specific account |
Account |
No | Accounts are the tenant boundary themselves |
User |
No | Users exist at the account level, not within it |
How reads are filtered¶
EF Core Global Query Filters automatically append WHERE AccountId = @currentAccountId to every query against a multi-tenant entity. You do not add this manually - it is impossible to forget.
sequenceDiagram
participant Handler as Command/Query Handler
participant Repo as AnimalRepository
participant DbCtx as PetfolioDbContext
participant TP as ITenantProvider
participant DB as MySQL
Handler->>Repo: GetAllAsync()
Repo->>DbCtx: Animals.ToListAsync()
DbCtx->>TP: What is AccountId?
TP-->>DbCtx: 550e8400-...
DbCtx->>DB: SELECT * FROM Animals<br/>WHERE AccountId = '550e8400-...'
DB-->>DbCtx: Rows for this account only
DbCtx-->>Repo: List of Animal
Repo-->>Handler: List of Animal
How the filter is configured¶
In OnModelCreating, PetfolioDbContext finds every entity that implements IMultiTenant and builds an expression tree filter for each one:
The key detail is Expression.Constant(this) - it references the DbContext instance, which means EF Core re-evaluates TenantProvider.AccountId on every query even though the model is cached. Without this, the filter would be frozen to whatever account ID was active when the model was first built.
Filters cannot be bypassed accidentally
Global query filters are applied automatically by EF Core at the database level. The only way to bypass them is to explicitly call .IgnoreQueryFilters(), which makes the intent obvious in the code.
How writes are stamped¶
When a new entity that implements IMultiTenant is saved, SaveChangesAsync reads the AccountId from ITenantProvider and stamps it onto the entity automatically.
sequenceDiagram
participant Handler as Command Handler
participant Repo as AnimalRepository
participant DbCtx as PetfolioDbContext
participant TP as ITenantProvider
participant DB as MySQL
Handler->>Repo: CreateAsync(animal)
Repo->>DbCtx: Animals.AddAsync(animal)
Handler->>DbCtx: SaveChangesAsync()
DbCtx->>DbCtx: Finds new IMultiTenant entities<br/>with empty AccountId
DbCtx->>TP: What is AccountId?
TP-->>DbCtx: 550e8400-...
DbCtx->>DbCtx: Sets animal.AccountId = 550e8400-...
DbCtx->>DB: INSERT INTO Animals (..., AccountId)<br/>VALUES (..., '550e8400-...')
DB-->>DbCtx: Success
The SaveChangesAsync override¶
This code runs for every save operation. For each newly added IMultiTenant entity with an empty AccountId:
- It reads
AccountIdfromITenantProvider - If
AccountIdisnull, it throwsInvalidOperationExceptionwith the message: "Cannot save a multi-tenant entity without an authenticated user context." - If
AccountIdhas a value, it stamps it onto the entity viaSetAccountIdInternal
The fail-fast behaviour is deliberate - it is better to crash loudly than to save data without a tenant, which would make it invisible to everyone.
Only new entities are stamped
The stamping only applies to entities in the Added state with an empty AccountId. Modified entities are not re-stamped - their AccountId was set when they were created and should not change.
Data isolation¶
This diagram shows how two accounts share the same database tables but see completely separate data.
graph LR
subgraph "Account A (Alice)"
UA["User: Alice"]
AA["Account A"]
A1["Animal: Max"]
A2["Animal: Bella"]
UA -->|belongs to| AA
A1 -->|belongs to| AA
A2 -->|belongs to| AA
end
subgraph "Account B (Bob)"
UB["User: Bob"]
AB["Account B"]
B1["Animal: Rex"]
UB -->|belongs to| AB
B1 -->|belongs to| AB
end
classDef layer-domain fill:#7ed32126,stroke:#7ed321,stroke-width:2px
classDef layer-external fill:#9b9b9b2e,stroke:#9b9b9b,stroke-width:2px
classDef neutral fill:#c8c8c826,stroke:#999999,stroke-width:1.5px
class AA,AB layer-domain
class UA,UB layer-external
class A1,A2,B1 neutral
- When Alice queries animals, the filter returns Max and Bella only
- When Bob queries animals, the filter returns Rex only
- Neither can see the other's data - the database enforces this boundary
Key takeaways¶
ITenantProviderprovides the current user'sAccountId. It is implemented byCurrentUserService.IMultiTenantmarks entities that need tenant isolation. Currently onlyAnimalimplements it.- Reads are filtered automatically via EF Core Global Query Filters - developers do not add
WHEREclauses manually. - Writes are stamped automatically via the
SaveChangesAsyncoverride - newIMultiTenantentities get theAccountIdset before insert. - If there is no authenticated user context when saving a multi-tenant entity, the system throws rather than saving data without a tenant.
- The only way to bypass query filters is
.IgnoreQueryFilters(), which makes the intent explicit.
Related resources¶
- Auth and Multi-Tenancy Overview - cross-cutting concepts, claim contract, and planned features
- Claims and Identity - how
CurrentUserServicereads claims and providesAccountId - Testing - how to simulate different tenants in tests
- ADR-001: Authentication and Multi-Tenancy - design decisions and alternatives for multi-tenancy