Multi-Tenancy
Infrastructure components for automatic data isolation between tenants.
Overview
The multi-tenancy infrastructure provides:
| Component | Description |
|---|---|
TenantContextAccessor | Static AsyncLocal storage for current tenant |
TenantContext | ITenantContextWriter implementation |
TenantQueryFilterExtensions | EF Core query filter extensions |
TenantSaveChangesInterceptor | Auto-fills TenantId on save |
TenantFilterBypass | Cross-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:
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:
- Query-time evaluation: Values are read at query execution, not model compilation
- Thread safety:
AsyncLocalpreserves values across async/await - Single source of truth: All components read from the same location
Query Filters
Apply tenant filters to all IHasTenant entities automatically:
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:
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:
// 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 State | Action |
|---|---|
Added | Sets TenantId from current context if not already set |
Modified | Prevents TenantId modification (configurable) |
Configuration
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:
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:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply both filters
modelBuilder.ApplyTenantQueryFilters(this);
modelBuilder.ApplySoftDeleteQueryFilters();
}Generated SQL:
SELECT * FROM Products
WHERE TenantId = @tenantId
AND IsDeleted = 0DI Registration
Complete registration example:
// 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>());
});