Skip to main content

Overview

FullStackHero implements database-per-tenant multitenancy using Finbuckle.MultiTenant. Each tenant gets:

Isolated Database

Complete data separation with dedicated connection string

Custom Schema

Optional schema customization per tenant

Theme Support

Per-tenant branding and styling

Provisioning Workflow

Automated tenant setup with database migrations

Architecture

┌─────────────────────────────────────────────────────────┐
│               HTTP Request with tenant identifier             │
│  Header: X-Tenant: acme-corp  OR  Claim: tenant=acme-corp    │
└────────────────────────────┬────────────────────────────┘


         ┌─────────────────────────────────┐
         │    Finbuckle Tenant Resolution    │
         │  (Header/Claim/Query Strategy)  │
         └────────────────┬─────────────────┘


         ┌─────────────────────────────────┐
         │   TenantDbContext (Metadata)    │
         │  • AppTenantInfo              │
         │  • Connection strings          │
         │  • Validity period            │
         └────────────────┬─────────────────┘

         ┌─────────────┴─────────────────┐
         ▼                               ▼
┌────────────────┐        ┌────────────────┐
│  Tenant A DB  │        │  Tenant B DB  │
│  (acme-corp)  │        │  (globex-inc) │
│                │        │                │
│ • Users        │        │ • Users        │
│ • Roles        │        │ • Roles        │
│ • Orders       │        │ • Orders       │
└────────────────┘        └────────────────┘

Tenant Information Model

AppTenantInfo

src/BuildingBlocks/Shared/Multitenancy/AppTenantInfo.cs
using Finbuckle.MultiTenant.Abstractions;

namespace FSH.Framework.Shared.Multitenancy;

public class AppTenantInfo : TenantInfo, IAppTenantInfo
{
    public AppTenantInfo()
    {
        Id = string.Empty;
        Identifier = string.Empty;
    }

    public AppTenantInfo(
        string id, 
        string name, 
        string? connectionString, 
        string adminEmail, 
        string? issuer = null)
        : this(id, id, name)
    {
        ConnectionString = connectionString ?? string.Empty;
        AdminEmail = adminEmail;
        IsActive = true;
        Issuer = issuer;

        // Default 1 month validity for new tenants
        ValidUpto = DateTime.UtcNow.AddMonths(1);
    }

    public string ConnectionString { get; set; } = string.Empty;
    public string AdminEmail { get; set; } = default!;
    public bool IsActive { get; set; }
    public DateTime ValidUpto { get; set; }
    public string? Issuer { get; set; }

    public void AddValidity(int months) =>
        ValidUpto = ValidUpto.AddMonths(months);

    public void SetValidity(in DateTime validTill)
    {
        if (ValidUpto >= validTill)
            throw new InvalidOperationException("Subscription cannot be backdated.");
        
        ValidUpto = validTill;
    }

    public void Activate()
    {
        if (Id == MultitenancyConstants.Root.Id)
            throw new InvalidOperationException("Invalid Tenant");

        IsActive = true;
    }

    public void Deactivate()
    {
        if (Id == MultitenancyConstants.Root.Id)
            throw new InvalidOperationException("Invalid Tenant");

        IsActive = false;
    }
}

Root Tenant

There’s always a special “root” tenant for system administration:
src/BuildingBlocks/Shared/Multitenancy/MultitenancyConstants.cs
namespace FSH.Framework.Shared.Multitenancy;

public static class MultitenancyConstants
{
    public static class Root
    {
        public const string Id = "root";
        public const string Name = "Root";
        public const string EmailAddress = "[email protected]";
    }

    public const string Identifier = "tenant";
    public const string Schema = "tenant";
}
The root tenant is protected - it cannot be deactivated or deleted.

Tenant Resolution Strategies

Finbuckle supports multiple strategies for identifying the current tenant:

1. Header Strategy

GET /api/v1/users
X-Tenant: acme-corp

2. Claim Strategy

Tenant identifier from JWT token:
{
  "sub": "user-123",
  "tenant": "acme-corp",
  "email": "[email protected]"
}

3. Query String Strategy

GET /api/v1/users?tenant=acme-corp

Configuration

src/Modules/Multitenancy/MultitenancyModule.cs
public void ConfigureServices(IHostApplicationBuilder builder)
{
    builder.Services
        .AddMultiTenant<AppTenantInfo>(options =>
        {
            // Event after tenant is resolved
            options.Events.OnTenantResolveCompleted = async context =>
            {
                // Cache resolved tenant in distributed cache
                if (context.MultiTenantContext.StoreInfo?.StoreType 
                    != typeof(DistributedCacheStore<AppTenantInfo>))
                {
                    var sp = ((HttpContext)context.Context!).RequestServices;
                    var distributedStore = sp
                        .GetRequiredService<IEnumerable<IMultiTenantStore<AppTenantInfo>>>()
                        .FirstOrDefault(s => s.GetType() 
                            == typeof(DistributedCacheStore<AppTenantInfo>));

                    await distributedStore!.AddAsync(
                        context.MultiTenantContext.TenantInfo!);
                }
            };
        })
        .WithClaimStrategy(ClaimConstants.Tenant)
        .WithHeaderStrategy(MultitenancyConstants.Identifier)
        .WithDelegateStrategy(async context =>
        {
            if (context is not HttpContext httpContext) return null;

            if (!httpContext.Request.Query.TryGetValue("tenant", out var tenantIdentifier) ||
                string.IsNullOrEmpty(tenantIdentifier))
                return null;

            return await Task.FromResult(tenantIdentifier.ToString());
        })
        .WithDistributedCacheStore(TimeSpan.FromMinutes(60))
        .WithStore<EFCoreStore<TenantDbContext, AppTenantInfo>>(ServiceLifetime.Scoped);
}
Strategies are tried in order. First match wins.

Multitenancy Module

Module Implementation

src/Modules/Multitenancy/MultitenancyModule.cs
using Finbuckle.MultiTenant.AspNetCore.Extensions;
using FSH.Framework.Web.Modules;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Hosting;

namespace FSH.Modules.Multitenancy;

public sealed class MultitenancyModule : IModule
{
    public void ConfigureServices(IHostApplicationBuilder builder)
    {
        builder.Services.AddScoped<ITenantService, TenantService>();
        builder.Services.AddScoped<ITenantThemeService, TenantThemeService>();
        builder.Services.AddScoped<ITenantProvisioningService, TenantProvisioningService>();
        builder.Services.AddHostedService<TenantAutoProvisioningHostedService>();

        // Register tenant metadata DbContext
        builder.Services.AddHeroDbContext<TenantDbContext>();

        // Configure Finbuckle MultiTenant (see above)
        // ...

        builder.Services.AddHealthChecks()
            .AddDbContextCheck<TenantDbContext>(
                name: "db:multitenancy",
                failureStatus: HealthStatus.Unhealthy)
            .AddCheck<TenantMigrationsHealthCheck>(
                name: "db:tenants-migrations",
                failureStatus: HealthStatus.Healthy);
    }

    public void MapEndpoints(IEndpointRouteBuilder endpoints)
    {
        var group = endpoints.MapGroup("api/v{version:apiVersion}/tenants")
            .WithTags("Tenants")
            .WithApiVersionSet(versionSet);
        
        ChangeTenantActivationEndpoint.Map(group);
        GetTenantsEndpoint.Map(group);
        UpgradeTenantEndpoint.Map(group);
        CreateTenantEndpoint.Map(group);
        GetTenantStatusEndpoint.Map(group);
        GetTenantProvisioningStatusEndpoint.Map(group);
        GetTenantThemeEndpoint.Map(group);
        UpdateTenantThemeEndpoint.Map(group);
    }
}

TenantDbContext

Stores tenant metadata (not tenant data):
src/Modules/Multitenancy/Data/TenantDbContext.cs
using Finbuckle.MultiTenant.EntityFrameworkCore.Stores;
using FSH.Framework.Shared.Multitenancy;
using Microsoft.EntityFrameworkCore;

namespace FSH.Modules.Multitenancy.Data;

public class TenantDbContext : EFCoreStoreDbContext<AppTenantInfo>
{
    public const string Schema = "tenant";

    public TenantDbContext(DbContextOptions<TenantDbContext> options)
        : base(options)
    {
    }

    public DbSet<TenantProvisioning> TenantProvisionings => Set<TenantProvisioning>();
    public DbSet<TenantProvisioningStep> TenantProvisioningSteps => Set<TenantProvisioningStep>();
    public DbSet<TenantTheme> TenantThemes => Set<TenantTheme>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ArgumentNullException.ThrowIfNull(modelBuilder);

        base.OnModelCreating(modelBuilder);
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(TenantDbContext).Assembly);
    }
}
TenantDbContext stores tenant metadata, not tenant data. Each tenant’s data lives in its own database.

Tenant Provisioning

Creating a new tenant involves:
1

Create tenant record

Insert AppTenantInfo into TenantDbContext
2

Create database

Execute CREATE DATABASE on the target server
3

Run migrations

Apply EF Core migrations to the new database
4

Seed initial data

Create default admin user, roles, permissions

Provisioning Workflow

Example Provisioning Logic
public async Task<TenantProvisioningResult> ProvisionTenantAsync(
    string tenantId,
    string tenantName,
    string adminEmail,
    CancellationToken ct)
{
    // Step 1: Validate tenant doesn't exist
    var exists = await _tenantDbContext.TenantInfo
        .AnyAsync(t => t.Id == tenantId, ct);
    if (exists)
        throw new ConflictException($"Tenant '{tenantId}' already exists.");

    // Step 2: Generate connection string
    var connectionString = GenerateConnectionString(tenantId);

    // Step 3: Create tenant metadata
    var tenant = new AppTenantInfo(
        id: tenantId,
        name: tenantName,
        connectionString: connectionString,
        adminEmail: adminEmail);

    _tenantDbContext.TenantInfo.Add(tenant);
    await _tenantDbContext.SaveChangesAsync(ct);

    // Step 4: Create database (async job)
    await _jobClient.EnqueueAsync<TenantProvisioningJob>(
        job => job.ExecuteAsync(tenantId, ct));

    return new TenantProvisioningResult
    {
        TenantId = tenantId,
        Status = ProvisioningStatus.Pending
    };
}

Using Tenant Context

In Handlers

public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand, OrderDto>
{
    private readonly ICurrentUser _currentUser;
    private readonly OrderDbContext _dbContext;

    public async ValueTask<OrderDto> Handle(
        CreateOrderCommand command, 
        CancellationToken ct)
    {
        // Tenant is automatically set via query filter
        var order = Order.Create(
            customerId: _currentUser.GetUserId().ToString(),
            items: command.Items);

        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync(ct);

        return MapToDto(order);
    }
}

Automatic Tenant Filtering

DbContext Configuration
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Apply tenant filter to all IHasTenant entities
    modelBuilder.Entity<Order>()
        .HasQueryFilter(e => e.TenantId == _currentTenant.Id);
}

Explicit Tenant Access

public class ReportService
{
    private readonly IMultiTenantContext<AppTenantInfo> _tenantContext;

    public async Task<TenantReport> GenerateReportAsync()
    {
        var tenant = _tenantContext.TenantInfo;
        
        return new TenantReport
        {
            TenantId = tenant.Id,
            TenantName = tenant.Name,
            ConnectionString = tenant.ConnectionString,
            IsActive = tenant.IsActive,
            ValidUpto = tenant.ValidUpto
        };
    }
}

Tenant Themes

Per-tenant branding:
public class TenantTheme
{
    public string TenantId { get; set; } = default!;
    public string? PrimaryColor { get; set; }
    public string? SecondaryColor { get; set; }
    public string? LogoUrl { get; set; }
    public string? FaviconUrl { get; set; }
    public string? CustomCss { get; set; }
}
Themes are stored in TenantDbContext and loaded per request.

Security Considerations

Connection String Security

Store connection strings encrypted at rest

Tenant Validation

Always validate tenant is active before processing requests

Tenant Isolation

Use query filters to prevent cross-tenant data access

Admin Segregation

Root tenant admins can’t access other tenant data

Testing with Tenants

public class MultitenantTestBase
{
    protected AppTenantInfo CreateTestTenant(string id = "test-tenant")
    {
        return new AppTenantInfo(
            id: id,
            name: "Test Tenant",
            connectionString: "Data Source=:memory:",
            adminEmail: "[email protected]");
    }

    protected IMultiTenantContext<AppTenantInfo> CreateTenantContext(
        AppTenantInfo tenant)
    {
        var context = new Mock<IMultiTenantContext<AppTenantInfo>>();
        context.Setup(x => x.TenantInfo).Returns(tenant);
        return context.Object;
    }
}

public class CreateOrderTests : MultitenantTestBase
{
    [Fact]
    public async Task CreateOrder_WithTenant_SetsTenantId()
    {
        // Arrange
        var tenant = CreateTestTenant("acme-corp");
        var tenantContext = CreateTenantContext(tenant);
        var handler = new CreateOrderCommandHandler(
            CreateDbContext(tenant), 
            tenantContext);

        // Act
        var result = await handler.Handle(
            new CreateOrderCommand(items: [...]), 
            CancellationToken.None);

        // Assert
        Assert.Equal("acme-corp", result.TenantId);
    }
}

Next Steps

Modular Monolith

Learn how modules interact in a multi-tenant environment

Domain-Driven Design

Understand tenant context in domain entities

Build docs developers (and LLMs) love