A robust, extensible multi-tenancy solution for Entity Framework Core with schema-per-tenant isolation on PostgreSQL.
- Schema-Per-Tenant Isolation: Complete data separation at the database level
- Pluggable Tenant Resolution: Multiple built-in resolvers (header, claims, subdomain, path, route, query string, API key)
- Control Database: Optional centralized tenant metadata storage with status tracking, encrypted credentials, and API key authentication
- Multiple DbContext Support: Register multiple tenant-aware contexts with per-context migration history tables
- Automatic Migration Management: Apply migrations across all tenant schemas
- Full Tenant Lifecycle: Provision, archive, restore, and delete tenants
- Event System: Subscribe to tenant lifecycle events
- Health Checks: Monitor tenant database health
- Tenant Validation: Built-in schema-exists validator rejects unknown tenant IDs with a 403 before they hit the database
- Extensible Architecture: Add custom strategies, resolvers, and seeders
- .NET 8.0+ SDK
- PostgreSQL 12+ (running and accessible)
- A PostgreSQL connection string (e.g.,
Host=localhost;Database=myapp;Username=postgres;Password=secret)
dotnet add package TenantCore.EntityFramework
dotnet add package TenantCore.EntityFramework.PostgreSqlThe TKey type parameter represents your tenant identifier type. Supported types are string and Guid. When using the Control Database feature, TKey must be convertible to Guid.
public class AppDbContext : TenantDbContext<string>
{
public DbSet<Product> Products => Set<Product>();
public AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantContextAccessor<string> tenantContextAccessor,
TenantCoreOptions tenantOptions)
: base(options, tenantContextAccessor, tenantOptions)
{
}
}Each tenant gets its own PostgreSQL schema. For example, a tenant with ID "acme" and a prefix of "tenant_" will have all its tables created under the tenant_acme schema.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required.");
// Configure TenantCore
builder.Services.AddTenantCore<string>(options =>
{
options.UsePostgreSql(connectionString);
options.UseSchemaPerTenant(schema =>
{
schema.SchemaPrefix = "tenant_";
});
// Exclude paths that must work without a tenant context
options.ExcludePaths("/api/tenants", "/health", "/swagger");
});
// Register tenant resolution (by HTTP header in this example)
builder.Services.AddHeaderTenantResolver<string>();
// Validate that the tenant exists and is active before allowing access
builder.Services.AddActiveTenantExistsValidator<AppDbContext, string>();
// Register the tenant-aware DbContext
builder.Services.AddTenantDbContextPostgreSql<AppDbContext, string>(connectionString);
var app = builder.Build();
// Add tenant resolution middleware
// Place after UseAuthentication() if using claims-based resolution,
// but before any endpoints that require tenant context.
app.UseTenantResolution<string>();Important:
ExcludePathsis required for any endpoints that operate outside a tenant context (e.g., tenant provisioning, health checks). Without it, those endpoints will fail because the middleware cannot resolve a tenant.
// Provision a new tenant (no X-Tenant-Id header needed -- path is excluded)
app.MapPost("/api/tenants/{tenantId}", async (
string tenantId,
ITenantManager<string> tenantManager) =>
{
await tenantManager.ProvisionTenantAsync(tenantId);
return Results.Created($"/api/tenants/{tenantId}", new { tenantId });
});
// Tenant-scoped endpoint (X-Tenant-Id header required)
app.MapGet("/api/products", async (AppDbContext db) =>
{
var products = await db.Products.ToListAsync();
return Results.Ok(products);
});
app.Run();Because TenantDbContext<TKey> requires ITenantContextAccessor and TenantCoreOptions in its constructor, you need a design-time factory so that dotnet ef migrations commands work without the full DI container:
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseNpgsql();
return new AppDbContext(
optionsBuilder.Options,
new DesignTimeTenantContextAccessor(),
new TenantCoreOptions());
}
}
// Minimal accessor that returns no tenant context at design time
public class DesignTimeTenantContextAccessor : ITenantContextAccessor<string>
{
public TenantContext<string>? TenantContext => null;
public void SetTenantContext(TenantContext<string>? context) { }
}Then generate and apply migrations:
dotnet ef migrations add Initial --context AppDbContextYou do not need to run dotnet ef database update manually. Migrations are applied per-tenant schema when you call ProvisionTenantAsync or use the startup migration feature.
TenantCore includes several built-in tenant resolvers. Multiple resolvers can be registered and will be evaluated in priority order (higher priority values run first).
| Resolver | Default Priority | Registration Helper |
|---|---|---|
| Claims | 200 | AddClaimsTenantResolver<TKey>() |
| API Key | 175 | AddApiKeyTenantResolver<TKey>() |
| Route Value | 150 | Manual AddScoped |
| Path | 125 | AddPathTenantResolver<TKey>() |
| Header | 100 | AddHeaderTenantResolver<TKey>() |
| Subdomain | 50 | AddSubdomainTenantResolver<TKey>() |
| Query String | 25 | Manual AddScoped |
builder.Services.AddHeaderTenantResolver<string>("X-Tenant-Id");builder.Services.AddClaimsTenantResolver<string>("tenant_id");builder.Services.AddSubdomainTenantResolver<string>("example.com");
// tenant1.example.com -> tenant1builder.Services.AddScoped<ITenantResolver<string>>(sp =>
new QueryStringTenantResolver<string>(
sp.GetRequiredService<IHttpContextAccessor>(),
"tenant"));
// /api/products?tenant=tenant1 -> tenant1builder.Services.AddScoped<ITenantResolver<string>>(sp =>
new RouteValueTenantResolver<string>(
sp.GetRequiredService<IHttpContextAccessor>(),
"tenantId"));
// /api/{tenantId}/products -> extracts from route// By segment index (0-based)
builder.Services.AddPathTenantResolver<string>(segmentIndex: 0);
// /{tenant}/api/products -> tenant
// By path prefix
builder.Services.AddPathTenantResolverWithPrefix<string>("/api");
// /api/{tenant}/products -> tenantbuilder.Services.AddApiKeyTenantResolver<Guid>("X-Api-Key");
// Verifies tenant API key using salted PBKDF2-SHA256 hashing in the control database
// Only returns Active tenantspublic class MyCustomResolver : ITenantResolver<string>
{
public int Priority => 100;
public Task<string?> ResolveTenantAsync(CancellationToken ct = default)
{
// Your custom logic here
return Task.FromResult<string?>("tenant1");
}
}
// Register
builder.Services.AddScoped<ITenantResolver<string>, MyCustomResolver>();By default, any string passed in the X-Tenant-Id header is accepted as a tenant ID. If the corresponding schema doesn't exist, the request fails with a raw database error. To prevent this, register the built-in active-tenant validator:
builder.Services.AddActiveTenantExistsValidator<AppDbContext, string>();This checks whether the tenant's schema actually exists in the database before the request reaches your endpoints. When a control database is also configured, it additionally verifies the tenant's status is Active.
Invalid or unknown tenant IDs receive an HTTP 403 Forbidden response. Validated results are cached (default: 5 minutes) to avoid repeated database lookups.
You can also implement a custom validator:
public class MyTenantValidator : ITenantValidator<string>
{
public Task<bool> ValidateTenantAsync(string tenantId, CancellationToken ct = default)
{
// Your custom validation logic
return Task.FromResult(true);
}
}
builder.Services.AddScoped<ITenantValidator<string>, MyTenantValidator>();Entities that should live in a shared schema (e.g., public) rather than per-tenant schemas can be configured by overriding ConfigureSharedEntities in your DbContext:
public class AppDbContext : TenantDbContext<string>
{
public DbSet<Product> Products => Set<Product>();
public DbSet<GlobalConfiguration> GlobalConfigurations => Set<GlobalConfiguration>();
// ... constructor
protected override void ConfigureSharedEntities(ModelBuilder modelBuilder)
{
modelBuilder.Entity<GlobalConfiguration>()
.ToTable("GlobalConfiguration", "public");
}
}Tables configured this way are placed in the shared schema and are accessible to all tenants.
options.ConfigureMigrations(migrations =>
{
migrations.ApplyOnStartup = true;
migrations.ParallelMigrations = 4;
migrations.Timeout = TimeSpan.FromMinutes(5);
});var tenantManager = serviceProvider.GetRequiredService<ITenantManager<string>>();
// Migrate specific tenant
await tenantManager.MigrateTenantAsync("tenant1");
// Migrate all tenants
await tenantManager.MigrateAllTenantsAsync();When your application has distinct bounded contexts (e.g., products and inventory), you can register multiple tenant-aware DbContexts against the same database. Each context manages its own entities and can be migrated independently.
Without per-context migration history tables, EF Core would see the other context's migrations as "unknown" in the shared __EFMigrationsHistory table and refuse to operate correctly. Per-context history tables solve this by giving each context its own isolated migration tracking.
public class InventoryDbContext : TenantDbContext<string>
{
public DbSet<Order> Orders => Set<Order>();
public InventoryDbContext(
DbContextOptions<InventoryDbContext> options,
ITenantContextAccessor<string> tenantContextAccessor,
TenantCoreOptions tenantOptions)
: base(options, tenantContextAccessor, tenantOptions)
{
}
}Each context gets its own migration history table within each tenant schema:
// Each context tracks migrations in its own history table
builder.Services.AddTenantDbContextPostgreSql<AppDbContext, string>(
connectionString,
migrationsAssembly: "MyApp",
migrationHistoryTable: "__ProductMigrations");
builder.Services.AddTenantDbContextPostgreSql<InventoryDbContext, string>(
connectionString,
migrationsAssembly: "MyApp",
migrationHistoryTable: "__InventoryMigrations");
// Register a migration hosted service for each context
builder.Services.AddTenantMigrationHostedService<AppDbContext, string>();
builder.Services.AddTenantMigrationHostedService<InventoryDbContext, string>();This produces the following structure per tenant schema:
tenant_acme/
Products (AppDbContext)
Orders (InventoryDbContext)
__ProductMigrations (AppDbContext history)
__InventoryMigrations (InventoryDbContext history)
Each context needs its own IDesignTimeDbContextFactory and its own migration output directory:
# AppDbContext migrations (default output directory)
dotnet ef migrations add Initial --context AppDbContext
# InventoryDbContext migrations (separate output directory)
dotnet ef migrations add Initial --context InventoryDbContext --output-dir Migrations/InventoryEach context has its own TenantMigrationRunner that can be resolved from DI:
app.MapPost("/api/tenants/{tenantId}/migrate", async (
string tenantId,
TenantMigrationRunner<AppDbContext, string> appRunner,
TenantMigrationRunner<InventoryDbContext, string> inventoryRunner) =>
{
await appRunner.MigrateTenantAsync(tenantId);
await inventoryRunner.MigrateTenantAsync(tenantId);
return Results.Ok();
});Important:
ITenantManager.ProvisionTenantAsynconly applies migrations for the primary context (the first one registered). For additional contexts, you must explicitly call theirTenantMigrationRunner<TContext, TKey>.MigrateTenantAsyncduring provisioning.
Note: If you only have a single DbContext, you don't need to specify
migrationHistoryTable. The default__EFMigrationsHistorytable will be used.
await tenantManager.ProvisionTenantAsync("new-tenant");await tenantManager.ArchiveTenantAsync("tenant-to-archive");await tenantManager.RestoreTenantAsync("archived-tenant");// Soft delete (renames schema)
await tenantManager.DeleteTenantAsync("tenant-id", hardDelete: false);
// Hard delete (drops schema)
await tenantManager.DeleteTenantAsync("tenant-id", hardDelete: true);Use ITenantScopeFactory to temporarily switch tenant context for background jobs or cross-tenant operations. IDbContextFactory<TContext> is automatically available after registering a tenant DbContext.
public class CrossTenantService
{
private readonly ITenantScopeFactory<string> _scopeFactory;
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public CrossTenantService(
ITenantScopeFactory<string> scopeFactory,
IDbContextFactory<AppDbContext> dbFactory)
{
_scopeFactory = scopeFactory;
_dbFactory = dbFactory;
}
public async Task ProcessAllTenantsAsync(IEnumerable<string> tenantIds)
{
foreach (var tenantId in tenantIds)
{
// Create a scope for the target tenant
using var scope = _scopeFactory.CreateScope(tenantId);
await using var db = await _dbFactory.CreateDbContextAsync();
// All operations here use the scoped tenant's schema
var products = await db.Products.ToListAsync();
// ... process products
}
}
}Seed initial data when provisioning new tenants:
public class TenantDataSeeder : ITenantSeeder<string>
{
public int Order => 0; // Lower values run first
public async Task SeedAsync(
DbContext context,
string tenantId,
CancellationToken cancellationToken = default)
{
context.Set<Product>().Add(new Product
{
Name = "Welcome Product",
Description = "Initial product for new tenants"
});
await context.SaveChangesAsync(cancellationToken);
}
}
// Register in DI
builder.Services.AddScoped<ITenantSeeder<string>, TenantDataSeeder>();Subscribe to tenant lifecycle events:
public class TenantEventHandler : ITenantEventSubscriber<string>
{
public Task OnTenantCreatedAsync(TenantCreatedEvent<string> @event, CancellationToken ct)
{
Console.WriteLine($"Tenant {@event.TenantId} was created");
return Task.CompletedTask;
}
// Also implement: OnTenantDeletedAsync, OnTenantArchivedAsync,
// OnTenantRestoredAsync, OnMigrationAppliedAsync, OnTenantResolvedAsync
}
builder.Services.AddTenantEventSubscriber<string, TenantEventHandler>();builder.Services.AddTenantHealthChecks<AppDbContext, string>("tenants");The Control Database feature provides centralized tenant metadata storage with support for:
- Tenant status tracking (Pending, Active, Suspended, Disabled, FlaggedForDelete)
- Encrypted database credentials
- API key authentication (salted PBKDF2-SHA256 hashed)
- Caching for improved performance
// Add control database with PostgreSQL
builder.Services.AddTenantControlDatabase(
dbOptions => dbOptions.UseNpgsql(controlDbConnectionString),
options =>
{
options.Schema = "tenant_control";
options.EnableCaching = true;
options.CacheDuration = TimeSpan.FromMinutes(5);
options.ApplyMigrationsOnStartup = true;
options.MigratableStatuses = [TenantStatus.Pending, TenantStatus.Active];
});When the control database is configured, use the extended provisioning method:
var tenantManager = app.Services.GetRequiredService<TenantManager<AppDbContext, Guid>>();
var request = new CreateTenantRequest(
TenantSlug: "acme-corp",
TenantSchema: "tenant_acme",
TenantApiKey: "sk_live_abc123..." // Will be hashed with salted PBKDF2-SHA256
);
var tenant = await tenantManager.ProvisionTenantAsync(Guid.NewGuid(), request);
// Creates control DB record (Pending) -> provisions schema -> sets status to ActiveImplement your own tenant storage by implementing ITenantStore:
public class MyTenantStore : ITenantStore
{
// Implement all ITenantStore methods
}
// Register
builder.Services.AddTenantStore<MyTenantStore>(options =>
{
options.EnableCaching = true;
});| Field | Description |
|---|---|
| TenantId | Unique identifier (Guid) |
| TenantSlug | URL-friendly identifier |
| Status | Tenant status enum |
| TenantSchema | Database schema name |
| TenantDatabase | Optional separate database |
| TenantDbServer | Optional separate server |
| TenantDbUser | Optional database user |
| TenantDbPasswordEncrypted | Encrypted password (Data Protection API) |
| TenantApiKeyHash | Salted PBKDF2-SHA256 hash of API key |
| CreatedAt / UpdatedAt | Timestamps |
builder.Services.AddTenantCore<string>(options =>
{
// Connection
options.UsePostgreSql(connectionString);
// Schema isolation
options.UseSchemaPerTenant(schema =>
{
schema.SchemaPrefix = "tenant_";
schema.SharedSchema = "public";
schema.ArchivedSchemaPrefix = "archived_";
});
// Migrations
options.ConfigureMigrations(migrations =>
{
migrations.ApplyOnStartup = true;
migrations.ParallelMigrations = 4;
migrations.FailureBehavior = MigrationFailureBehavior.ContinueOthers;
migrations.Timeout = TimeSpan.FromMinutes(5);
});
// Behavior
options.OnTenantNotFound(TenantNotFoundBehavior.Throw);
options.EnableCaching(TimeSpan.FromMinutes(5));
options.DisableTenantValidation();
// Exclude paths from tenant resolution
options.ExcludePaths("/api/tenants", "/health", "/swagger");
});When your migrations are in a separate assembly (common in clean architecture):
builder.Services.AddTenantDbContextPostgreSql<AppDbContext, string>(
connectionString,
migrationsAssembly: "MyApp.Infrastructure");| Database | Package | Status |
|---|---|---|
| PostgreSQL | TenantCore.EntityFramework.PostgreSql | Supported |
| SQL Server | TenantCore.EntityFramework.SqlServer | Planned |
| MySQL | TenantCore.EntityFramework.MySql | Planned |
A complete sample Web API is included in the samples/TenantCore.Sample.WebApi directory, demonstrating:
- Tenant provisioning and management endpoints
- Tenant-scoped CRUD operations
- Multiple DbContexts with per-context migration history tables (
ApplicationDbContext+InventoryDbContext) - Health check configuration
- Swagger/OpenAPI integration
- Optional Control Database integration
Run the sample:
cd samples/TenantCore.Sample.WebApi
dotnet runTo enable the Control Database feature in the sample:
dotnet run -- --TenantCore:UseControlDatabase=true- .NET 8.0 or .NET 10.0
- Entity Framework Core 8.x or 10.x
- PostgreSQL 12+ (for PostgreSQL provider)
Migrations fail at design time (Unable to create an instance of 'AppDbContext'): You need an IDesignTimeDbContextFactory<TContext> for each DbContext. See Creating EF Core Migrations above.
Tenant provisioning endpoint returns a tenant-not-found error: Add the provisioning path to ExcludePaths so it bypasses tenant resolution. See the options.ExcludePaths(...) call in the Quick Start.
Unknown tenant ID causes a raw database error (e.g., 42P01: relation "tenant_unknown.Products" does not exist): Register the active-tenant validator with AddActiveTenantExistsValidator<TContext, TKey>(). This rejects unknown tenants with a 403 before any database query runs.
Second DbContext migrations are not applied when provisioning: ProvisionTenantAsync only migrates the primary (first-registered) context. Call TenantMigrationRunner<TContext, TKey>.MigrateTenantAsync explicitly for each additional context.
This project is licensed under the MIT License - see the LICENSE file for details.
Contributions are welcome! Please open an issue or submit a pull request on GitHub.