Multi-Tenant SaaS¶
Verified by tests
SystemDispatcherBuilderTests, SuperAdminEndpointTests — library CI run #31657041675 (2026-08-13)
Build multi-tenant SaaS applications with Whizbang featuring tenant isolation, per-tenant databases, cross-tenant analytics, and tenant-specific customizations.
Updated
Tenancy is first-class in Whizbang: every IMessageContext carries a TenantId, perspective rows carry a tenant scope (row.Scope.TenantId), and the dispatcher exposes explicit tenant-scope builders — dispatcher.AsSystem().ForTenant("tenant-123"), .ForAllTenants() (uses TenantConstants.AllTenants, "*"), and .KeepTenant(). The per-tenant-database plumbing below (middleware, connection resolver) is one deployment architecture layered on top; the shipped ECommerce sample instead uses shared tables with tenant-scoped rows queried through lenses.
Architecture¶
flowchart TD
subgraph MTSA["Multi-Tenant SaaS Architecture"]
Request["HTTP Request<br/>X-Tenant-Id: tenant-a"]
Middleware["Tenant Identification<br/>Middleware"]
Dispatcher["Tenant-Aware Dispatcher<br/>(Routes to tenant DB)"]
TenantA["Tenant A<br/>DB"]
TenantB["Tenant B<br/>DB"]
TenantC["Tenant C<br/>DB"]
SharedDB["Shared<br/>DB"]
Request --> Middleware
Middleware --> Dispatcher
Dispatcher --> TenantA
Dispatcher --> TenantB
Dispatcher --> TenantC
Dispatcher --> SharedDB
end
class Request layer-command
class Middleware,Dispatcher layer-core
class TenantA,TenantB,TenantC,SharedDB layer-event
Key features: - ✅ Database per tenant (strongest isolation) - ✅ Tenant context propagation - ✅ Cross-tenant analytics - ✅ Tenant-specific customizations - ✅ Tenant onboarding automation
Tenant Identification¶
Tenant Context¶
TenantContext.cs:
Tenant Context
public class TenantContext {
private static readonly AsyncLocal<string?> _tenantId = new();
public static string? CurrentTenantId {
get => _tenantId.Value;
set => _tenantId.Value = value;
}
public static void Set(string tenantId) {
if (string.IsNullOrWhiteSpace(tenantId)) {
throw new ArgumentException("Tenant ID cannot be null or empty", nameof(tenantId));
}
_tenantId.Value = tenantId;
}
public static void Clear() {
_tenantId.Value = null;
}
}
Tenant Middleware¶
TenantIdentificationMiddleware.cs:
Tenant Middleware
public class TenantIdentificationMiddleware {
private readonly RequestDelegate _next;
private readonly ILogger<TenantIdentificationMiddleware> _logger;
public TenantIdentificationMiddleware(
RequestDelegate next,
ILogger<TenantIdentificationMiddleware> logger
) {
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context) {
// 1. Extract tenant ID from header
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
// 2. Fallback: Extract from subdomain (e.g., tenant-a.example.com)
if (string.IsNullOrWhiteSpace(tenantId)) {
var host = context.Request.Host.Host;
var parts = host.Split('.');
if (parts.Length > 2) {
tenantId = parts[0];
}
}
// 3. Fallback: Extract from JWT claim
if (string.IsNullOrWhiteSpace(tenantId)) {
tenantId = context.User.FindFirst("tenant_id")?.Value;
}
if (string.IsNullOrWhiteSpace(tenantId)) {
context.Response.StatusCode = 400;
await context.Response.WriteAsJsonAsync(new {
error = "Tenant ID is required"
});
return;
}
// 4. Set tenant context
TenantContext.Set(tenantId);
_logger.LogInformation("Request for tenant {TenantId}", tenantId);
try {
await _next(context);
} finally {
TenantContext.Clear();
}
}
}
Program.cs registration:
Tenant Middleware (2)
Database Per Tenant¶
Tenant Database Resolver¶
ITenantDatabaseResolver.cs:
Tenant Database Resolver
public interface ITenantDatabaseResolver {
string GetConnectionString(string tenantId);
}
public class TenantDatabaseResolver : ITenantDatabaseResolver {
private readonly Dictionary<string, string> _tenantConnectionStrings;
public TenantDatabaseResolver(IConfiguration configuration) {
_tenantConnectionStrings = configuration
.GetSection("Tenants")
.Get<Dictionary<string, TenantConfig>>()
?.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ConnectionString
) ?? new Dictionary<string, string>();
}
public string GetConnectionString(string tenantId) {
if (_tenantConnectionStrings.TryGetValue(tenantId, out var connectionString)) {
return connectionString;
}
throw new InvalidOperationException($"Tenant {tenantId} not found");
}
}
public record TenantConfig(
string ConnectionString,
string? CustomDomain,
Dictionary<string, string>? Settings
);
appsettings.json:
Tenant Database Resolver (2)
{
"Tenants": {
"tenant-a": {
"ConnectionString": "Host=localhost;Database=tenant_a;Username=postgres;Password=postgres",
"CustomDomain": "tenant-a.example.com",
"Settings": {
"MaxUsers": "100",
"Features": "analytics,exports"
}
},
"tenant-b": {
"ConnectionString": "Host=localhost;Database=tenant_b;Username=postgres;Password=postgres",
"CustomDomain": "tenant-b.example.com",
"Settings": {
"MaxUsers": "500",
"Features": "analytics,exports,api-access"
}
}
}
}
Tenant-Aware Database Connection¶
Program.cs:
Tenant-Aware Database Connection
builder.Services.AddScoped<NpgsqlConnection>(sp => {
var tenantId = TenantContext.CurrentTenantId
?? throw new InvalidOperationException("Tenant context not set");
var resolver = sp.GetRequiredService<ITenantDatabaseResolver>();
var connectionString = resolver.GetConnectionString(tenantId);
return new NpgsqlConnection(connectionString);
});
builder.Services.AddSingleton<ITenantDatabaseResolver, TenantDatabaseResolver>();
Tenant-Aware Receptors¶
CreateOrderReceptor.cs:
Tenant-Aware Receptors
using Whizbang.Core;
public class CreateOrderReceptor(
IDispatcher dispatcher,
ILogger<CreateOrderReceptor> logger
) : IReceptor<CreateOrderCommand, OrderCreatedEvent> {
public async ValueTask<OrderCreatedEvent> HandleAsync(
CreateOrderCommand message,
CancellationToken cancellationToken = default
) {
// The tenant travels with the message: IMessageContext.TenantId was stamped
// when the command was dispatched, and Whizbang persists it on the envelope,
// the event store rows, and the perspective row scope.
logger.LogInformation(
"Creating order {OrderId} for customer {CustomerId}",
message.OrderId,
message.CustomerId
);
var orderCreated = new OrderCreatedEvent {
OrderId = message.OrderId,
CustomerId = message.CustomerId,
LineItems = message.LineItems,
TotalAmount = message.TotalAmount,
CreatedAt = DateTime.UtcNow
};
// Published event inherits the tenant scope from the command's envelope
await dispatcher.PublishAsync(orderCreated);
return orderCreated;
}
}
The receptor body contains no tenant plumbing — tenant scope flows through the message envelope. If you run database-per-tenant, resolve the tenant connection in your DI registration (as shown above) rather than inside receptors.
Message Context Propagation¶
Tenant propagation is built in — IMessageContext has a first-class TenantId property:
Message Context Propagation
public interface IMessageContext {
MessageId MessageId { get; }
CorrelationId CorrelationId { get; }
MessageId CausationId { get; }
DateTimeOffset Timestamp { get; }
string? UserId { get; }
string? TenantId { get; } // ← first-class tenant scope
IReadOnlyDictionary<string, object> Metadata { get; }
// ... plus security scope and caller info
}
Explicit tenant scoping at dispatch — for system/maintenance operations, make the tenant scope explicit with the security builder:
Message Context Propagation (2)
// Cross-tenant system operation (TenantId = TenantConstants.AllTenants, "*")
await dispatcher.AsSystem().ForAllTenants().SendAsync(new ReindexAllTenantsCommand());
// System operation scoped to one tenant
await dispatcher.AsSystem().ForTenant("tenant-123").SendAsync(new TenantMaintenanceCommand());
// System operation preserving the ambient tenant
await dispatcher.AsSystem().KeepTenant().SendAsync(new MaintenanceCommand());
Result: every event envelope, event store row, and perspective row carries its tenant scope — cascaded messages inherit it automatically.
Cross-Tenant Analytics¶
Cross-Tenant Queries via Lenses¶
Perspective rows carry their tenant scope, so cross-tenant analytics is a lens query over row.Scope.TenantId — no metadata parsing, no separate ingestion pipeline. From the ECommerce sample:
samples/ECommerce/ECommerce.BFF.API/Lenses/OrderLens.cs (excerpt):
Cross-Tenant Lens Queries
using Microsoft.EntityFrameworkCore;
using Whizbang.Core.Lenses;
public class OrderLens(ILensQuery<OrderReadModel> query, ILogger<OrderLens> logger) : IOrderLens {
public async Task<IEnumerable<OrderReadModel>> GetByTenantIdAsync(string tenantId, CancellationToken cancellationToken = default) {
return await query.DefaultScope.Query
.Where(row => row.Scope.TenantId == tenantId)
.OrderByDescending(row => row.CreatedAt)
.Select(row => row.Data)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<OrderReadModel>> GetByStatusAsync(string tenantId, string status, CancellationToken cancellationToken = default) {
return await query.DefaultScope.Query
.Where(row => row.Scope.TenantId == tenantId && row.Data.Status == status)
.OrderByDescending(row => row.CreatedAt)
.Select(row => row.Data)
.ToListAsync(cancellationToken);
}
}
Super-admin endpoint (samples/ECommerce/ECommerce.BFF.API/Endpoints/SuperAdmin/GetOrdersByTenantEndpoint.cs):
Cross-Tenant Endpoint
using FastEndpoints;
/// <summary>
/// Get all orders for a specific tenant (super-admin view)
/// </summary>
public class GetOrdersByTenantEndpoint(IOrderLens orderLens) : EndpointWithoutRequest<IEnumerable<OrderReadModel>> {
public override void Configure() {
Get("/superadmin/orders/tenant/{tenantId}");
AllowAnonymous(); // TODO: Add authentication and super-admin authorization
}
public override async Task HandleAsync(CancellationToken ct) {
var tenantId = Route<string>("tenantId")!;
var orders = await orderLens.GetByTenantIdAsync(tenantId, ct);
Response = orders;
}
}
For pre-aggregated cross-tenant rollups (daily sales per tenant), materialize a perspective whose model keys on (date, tenantId) — the pure Apply pattern from the tutorials applies unchanged.
Tenant Onboarding¶
TenantProvisioningService.cs:
Tenant Onboarding
public class TenantProvisioningService {
private readonly NpgsqlConnection _masterDb;
private readonly ILogger<TenantProvisioningService> _logger;
public async Task ProvisionTenantAsync(
string tenantId,
string adminEmail,
string companyName,
CancellationToken ct = default
) {
_logger.LogInformation("Provisioning tenant {TenantId}", tenantId);
// 1. Create tenant database
await _masterDb.ExecuteAsync(
$"CREATE DATABASE tenant_{tenantId}"
);
// 2. Run migrations on new database
var tenantConnectionString = $"Host=localhost;Database=tenant_{tenantId};Username=postgres;Password=postgres";
using var tenantDb = new NpgsqlConnection(tenantConnectionString);
await tenantDb.OpenAsync(ct);
await ApplyMigrationsAsync(tenantDb, ct);
// 3. Create admin user
await tenantDb.ExecuteAsync(
"""
INSERT INTO users (user_id, email, role, tenant_id, created_at)
VALUES (@UserId, @Email, 'admin', @TenantId, NOW())
""",
new {
UserId = Guid.NewGuid().ToString("N"),
Email = adminEmail,
TenantId = tenantId
}
);
// 4. Create default settings
await tenantDb.ExecuteAsync(
"""
INSERT INTO tenant_settings (tenant_id, company_name, max_users, features, created_at)
VALUES (@TenantId, @CompanyName, 100, 'basic', NOW())
""",
new {
TenantId = tenantId,
CompanyName = companyName
}
);
_logger.LogInformation("Tenant {TenantId} provisioned successfully", tenantId);
}
private async Task ApplyMigrationsAsync(NpgsqlConnection db, CancellationToken ct) {
var migrationFiles = Directory.GetFiles("Migrations", "*.sql").OrderBy(f => f);
foreach (var file in migrationFiles) {
var sql = await File.ReadAllTextAsync(file, ct);
await db.ExecuteAsync(sql);
}
}
}
Tenant-Specific Customizations¶
Feature Flags per Tenant:
Tenant-Specific Customizations
public class TenantFeatureService {
private readonly ITenantDatabaseResolver _resolver;
public async Task<bool> IsFeatureEnabledAsync(string feature) {
var tenantId = TenantContext.CurrentTenantId
?? throw new InvalidOperationException("Tenant context not set");
var connectionString = _resolver.GetConnectionString(tenantId);
using var db = new NpgsqlConnection(connectionString);
await db.OpenAsync();
var features = await db.QuerySingleOrDefaultAsync<string>(
"SELECT features FROM tenant_settings WHERE tenant_id = @TenantId",
new { TenantId = tenantId }
);
return features?.Contains(feature) ?? false;
}
}
Usage:
Tenant-Specific Customizations (2)
public async ValueTask<OrderCreatedEvent> HandleAsync(
CreateOrderCommand message,
CancellationToken cancellationToken = default
) {
// ... create and publish orderCreated as usual ...
// Check if tenant has analytics feature
var hasAnalytics = await _featureService.IsFeatureEnabledAsync("analytics");
if (hasAnalytics) {
// Publish additional analytics events
await dispatcher.PublishAsync(new OrderAnalyticsRequestedEvent { OrderId = message.OrderId });
}
return orderCreated;
}
Key Takeaways¶
✅ First-Class Tenancy - IMessageContext.TenantId, tenant-scoped perspective rows, AsSystem().ForTenant(...) builders
✅ Database Per Tenant - Strongest isolation, independent scaling (optional architecture)
✅ Tenant Context Propagation - Tenant scope travels on every envelope automatically
✅ Cross-Tenant Analytics - Lens queries over row.Scope.TenantId
✅ Tenant Onboarding - Automated provisioning with migrations
✅ Feature Flags - Tenant-specific customizations
Alternative Patterns¶
Shared Database with Row-Level Security¶
Shared Database with Row-Level Security
-- PostgreSQL Row-Level Security
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.current_tenant')::text);
-- Set tenant before query
SET app.current_tenant = 'tenant-a';
SELECT * FROM orders; -- Only returns tenant-a orders
Pros: Single database, simpler infrastructure Cons: Weaker isolation, shared resources
Version 1.0.0 - Foundation Release | Last Updated: 2024-12-12