Skip to content

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
1
2
3
4
public interface ITenantProvider
{
    public Guid? AccountId { get; }
}

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
1
2
3
4
5
public interface IMultiTenant
{
    public Guid AccountId { get; }
    public void SetAccountIdInternal(Guid accountId);
}
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:

PetfolioDbContext.cs - ApplyMultiTenantQueryFilters (simplified)
// For each entity type implementing IMultiTenant, build:
//   e => (Guid?)e.AccountId == this.TenantProvider.AccountId

var accountIdProperty = Expression.Property(parameter, nameof(IMultiTenant.AccountId));
var nullableAccountId = Expression.Convert(accountIdProperty, typeof(Guid?));

// Reference through DbContext so EF Core re-evaluates per query
var contextExpression = Expression.Constant(this);
var serviceExpression = Expression.Property(contextExpression, nameof(TenantProvider));
var currentAccountId = Expression.Property(serviceExpression, nameof(ITenantProvider.AccountId));

var comparison = Expression.Equal(nullableAccountId, currentAccountId);
modelBuilder.Entity(clrType).HasQueryFilter(Expression.Lambda(comparison, parameter));

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

PetfolioDbContext.cs - SaveChangesAsync (excerpt)
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    var entries = ChangeTracker.Entries()
        .Where(e => e.State is EntityState.Added or EntityState.Modified)
        .ToList();

    foreach (var entry in entries)
    {
        var entity = entry.Entity as Entity;
        entity?.UpdateTimestamp();

        if (entry.State == EntityState.Added && entry.Entity is IMultiTenant multiTenantEntity
                                             && multiTenantEntity.AccountId == Guid.Empty)
        {
            var accountId = TenantProvider.AccountId
                            ?? throw new InvalidOperationException(
                                Validation.MultiTenant.NoUserContextError);

            multiTenantEntity.SetAccountIdInternal(accountId);
        }
    }

    return base.SaveChangesAsync(cancellationToken);
}

This code runs for every save operation. For each newly added IMultiTenant entity with an empty AccountId:

  1. It reads AccountId from ITenantProvider
  2. If AccountId is null, it throws InvalidOperationException with the message: "Cannot save a multi-tenant entity without an authenticated user context."
  3. If AccountId has a value, it stamps it onto the entity via SetAccountIdInternal

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

  • ITenantProvider provides the current user's AccountId. It is implemented by CurrentUserService.
  • IMultiTenant marks entities that need tenant isolation. Currently only Animal implements it.
  • Reads are filtered automatically via EF Core Global Query Filters - developers do not add WHERE clauses manually.
  • Writes are stamped automatically via the SaveChangesAsync override - new IMultiTenant entities get the AccountId set 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.