Skip to content

Multi-Tenancy

Infrastructure components for automatic data isolation between tenants.

Overview

The multi-tenancy infrastructure provides:

ComponentDescription
TenantContextAccessorStatic AsyncLocal storage for current tenant
TenantContextITenantContextWriter implementation
TenantQueryFilterExtensionsEF Core query filter extensions
TenantSaveChangesInterceptorAuto-fills TenantId on save
TenantFilterBypassCross-tenant access for admin scenarios

Architecture

┌────────────────────────────────────────────────────────┐
│                   Application Layer                     │
│  ┌─────────────────────┐    ┌─────────────────────┐   │
│  │   ITenantContext    │    │ ITenantFilterBypass │   │
│  │   (read-only)       │    │ (admin operations)  │   │
│  └──────────┬──────────┘    └──────────┬──────────┘   │
└─────────────┼───────────────────────────┼─────────────┘
              │                           │
┌─────────────┼───────────────────────────┼─────────────┐
│             ▼                           ▼              │
│      Infrastructure Layer                              │
│  ┌─────────────────────────────────────────────────┐  │
│  │           TenantContextAccessor (Static)         │  │
│  │           AsyncLocal<TenantHolder>               │  │
│  └─────────────────────────────────────────────────┘  │
│                         │                              │
│     ┌───────────────────┼───────────────────┐         │
│     ▼                   ▼                   ▼         │
│  ┌──────────┐    ┌─────────────┐    ┌────────────┐   │
│  │ Query    │    │ SaveChanges │    │ Filter     │   │
│  │ Filters  │    │ Interceptor │    │ Bypass     │   │
│  └──────────┘    └─────────────┘    └────────────┘   │
└────────────────────────────────────────────────────────┘

TenantContextAccessor

Static accessor using AsyncLocal for thread-safe tenant storage:

csharp
using Gryd.Infrastructure.Tenancy;

// Set current tenant (typically in middleware)
TenantContextAccessor.SetTenant(tenantId, "Tenant Name");

// Read current tenant
Guid? tenantId = TenantContextAccessor.TenantId;
string? name = TenantContextAccessor.TenantName;
bool hasTenant = TenantContextAccessor.HasTenant;

// Clear tenant context
TenantContextAccessor.Clear();

Why Static?

EF Core caches compiled models (including query filters) per DbContext type. Using a static accessor with AsyncLocal:

  1. Query-time evaluation: Values are read at query execution, not model compilation
  2. Thread safety: AsyncLocal preserves values across async/await
  3. Single source of truth: All components read from the same location

Query Filters

Apply tenant filters to all IHasTenant entities automatically:

csharp
using Gryd.Infrastructure.Tenancy;

public class AppDbContext : DbContext, ITenantQueryContext
{
    public DbSet<Product> Products => Set<Product>();
    
    // Required by ITenantQueryContext
    public Guid? CurrentTenantId => TenantContextAccessor.TenantId;
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        // Apply filters to all IHasTenant entities
        modelBuilder.ApplyTenantQueryFilters(this);
    }
}

ITenantQueryContext

Your DbContext must implement this interface:

csharp
public interface ITenantQueryContext
{
    Guid? CurrentTenantId { get; }
}

EF Core correctly parametrizes DbContext properties, ensuring the value is evaluated at query time.

SaveChanges Interceptor

Automatically manage TenantId during entity persistence:

csharp
// Registration
builder.Services.AddSingleton(new MultiTenancyOptions 
{ 
    IsEnabled = true 
});
builder.Services.AddScoped<TenantSaveChangesInterceptor>();

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.AddInterceptors(sp.GetRequiredService<TenantSaveChangesInterceptor>());
});

Behavior

Entity StateAction
AddedSets TenantId from current context if not already set
ModifiedPrevents TenantId modification (configurable)

Configuration

csharp
new MultiTenancyOptions
{
    IsEnabled = true,                    // Enable/disable multi-tenancy
    AllowTenantIdModification = false,   // Prevent TenantId changes
    AllowNullTenantId = false,           // Require tenant for new entities
    RequireTenantForOperations = true    // Throw if no tenant set
}

Tenant Filter Bypass

For administrative operations that need cross-tenant access:

csharp
using Gryd.Infrastructure.Tenancy;

public class AdminService
{
    private readonly ITenantFilterBypass _bypass;
    private readonly AppDbContext _context;
    
    public async Task<List<Product>> GetAllProductsAsync()
    {
        return await _bypass.ExecuteWithoutTenantFilterAsync(
            () => _context.Products.ToListAsync()
        );
    }
}

// Registration
builder.Services.AddScoped<ITenantFilterBypass, TenantFilterBypass>();

Security Warning

Only use bypass for controlled administrative scenarios. It removes tenant data isolation.

Soft Delete Filters

Apply automatic soft delete filtering:

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Apply both filters
    modelBuilder.ApplyTenantQueryFilters(this);
    modelBuilder.ApplySoftDeleteQueryFilters();
}

Generated SQL:

sql
SELECT * FROM Products 
WHERE TenantId = @tenantId 
  AND IsDeleted = 0

DI Registration

Complete registration example:

csharp
// Tenant context (read/write)
builder.Services.AddScoped<ITenantContextWriter, TenantContext>();
builder.Services.AddScoped<ITenantContext>(sp => 
    sp.GetRequiredService<ITenantContextWriter>());

// Filter bypass
builder.Services.AddScoped<ITenantFilterBypass, TenantFilterBypass>();

// Multi-tenancy options
builder.Services.AddSingleton(new MultiTenancyOptions { IsEnabled = true });

// Interceptor
builder.Services.AddScoped<TenantSaveChangesInterceptor>();

// DbContext with interceptor
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString);
    options.AddInterceptors(sp.GetRequiredService<TenantSaveChangesInterceptor>());
});

See Also

Released under the MIT License.