Isaac.

Multi-Tenancy in ASP.NET Core

Build scalable multi-tenant applications serving multiple customers.

By EMEPublished: February 20, 2025
multi-tenancysaasaspnet corearchitecturescalability

A Simple Analogy

Multi-tenancy is like apartment building management. Multiple customers (tenants) share infrastructure but each has isolated data and configuration. One bill, many customers.


Why Multi-Tenancy?

  • Cost efficiency: Share infrastructure across customers
  • Scalability: Serve more customers with same resources
  • Revenue: SaaS pricing models
  • Simplicity: Single deployment for all customers
  • Customization: Per-tenant configuration

Tenant Identification

// Middleware to identify tenant
public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;
    
    public async Task InvokeAsync(HttpContext context, ITenantService tenantService)
    {
        // Get tenant from subdomain
        var host = context.Request.Host.Host;
        var tenantId = host.Split('.')[0]; // customer.example.com -> customer
        
        // Or from header
        if (context.Request.Headers.TryGetValue("X-Tenant-ID", out var headerValue))
            tenantId = headerValue.ToString();
        
        // Or from route
        if (context.GetRouteData().Values.TryGetValue("tenantId", out var routeValue))
            tenantId = routeValue.ToString();
        
        var tenant = await tenantService.GetTenantAsync(tenantId);
        if (tenant == null)
            return;
        
        context.Items["Tenant"] = tenant;
        await _next(context);
    }
}

// Register
app.UseMiddleware<TenantResolutionMiddleware>();

Database Strategy

// Shared database with tenant column
public class Order
{
    public int Id { get; set; }
    public string TenantId { get; set; }  // Tenant isolation
    public string CustomerId { get; set; }
    public decimal Total { get; set; }
}

// Database per tenant
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    var tenantId = _httpContextAccessor.HttpContext.Items["TenantId"].ToString();
    var connectionString = $"Server=localhost;Database=tenant_{tenantId};...";
    optionsBuilder.UseSqlServer(connectionString);
}

// Hybrid: Shared catalog, separate data databases
// One central database (catalogs, tenants, users)
// One database per tenant for their data

Query Filtering by Tenant

public class OrderRepository
{
    private readonly AppDbContext _context;
    private readonly ITenantContext _tenantContext;
    
    public async Task<List<Order>> GetAllAsync()
    {
        return await _context.Orders
            .Where(o => o.TenantId == _tenantContext.TenantId)  // Always filter
            .ToListAsync();
    }
}

// Or use interceptors
public class TenantInterceptor : SaveChangesInterceptor
{
    private readonly ITenantContext _tenantContext;
    
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        // Automatically add TenantId to new entities
        foreach (var entry in eventData.Context.ChangeTracker.Entries())
        {
            if (entry.Entity is ITenantEntity tenantEntity && entry.State == EntityState.Added)
            {
                tenantEntity.TenantId = _tenantContext.TenantId;
            }
        }
        
        return new ValueTask<InterceptionResult<int>>(result);
    }
}

Per-Tenant Configuration

public interface ITenant
{
    string Id { get; }
    string Name { get; }
    Dictionary<string, string> Settings { get; }
}

public class TenantService
{
    private readonly IDistributedCache _cache;
    private readonly IRepository<Tenant> _repository;
    
    public async Task<ITenant> GetTenantAsync(string tenantId)
    {
        var cached = await _cache.GetStringAsync($"tenant_{tenantId}");
        if (!string.IsNullOrEmpty(cached))
            return JsonSerializer.Deserialize<Tenant>(cached);
        
        var tenant = await _repository.GetAsync(tenantId);
        
        if (tenant != null)
        {
            var json = JsonSerializer.Serialize(tenant);
            await _cache.SetStringAsync($"tenant_{tenantId}", json, 
                new DistributedCacheEntryOptions
                {
                    SlidingExpiration = TimeSpan.FromHours(1)
                });
        }
        
        return tenant;
    }
}

Dependency Injection Per Tenant

builder.Services.AddScoped(sp =>
{
    var httpContext = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
    var tenant = httpContext.Items["Tenant"] as ITenant;
    
    // Create configuration per tenant
    return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
        .UseSqlServer($"Server=localhost;Database=tenant_{tenant.Id};...")
        .Options);
});

Best Practices

  1. Prevent data leaks: Always filter by tenant
  2. Use interceptors: Auto-add tenant ID
  3. Cache tenant config: Reduce database queries
  4. Isolate databases: When compliance requires
  5. Monitor per-tenant: Track usage per customer

Related Concepts

  • Row-level security (RLS)
  • Tenant policies for authorization
  • Usage analytics per tenant
  • Custom theming per tenant

Summary

Multi-tenancy enables serving multiple customers efficiently from single infrastructure. Master data isolation, tenant identification, and per-tenant configuration to build scalable SaaS applications.