TenantCore.EntityFramework.PostgreSql
2.3.0
See the version list below for details.
dotnet add package TenantCore.EntityFramework.PostgreSql --version 2.3.0
NuGet\Install-Package TenantCore.EntityFramework.PostgreSql -Version 2.3.0
<PackageReference Include="TenantCore.EntityFramework.PostgreSql" Version="2.3.0" />
<PackageVersion Include="TenantCore.EntityFramework.PostgreSql" Version="2.3.0" />
<PackageReference Include="TenantCore.EntityFramework.PostgreSql" />
paket add TenantCore.EntityFramework.PostgreSql --version 2.3.0
#r "nuget: TenantCore.EntityFramework.PostgreSql, 2.3.0"
#:package TenantCore.EntityFramework.PostgreSql@2.3.0
#addin nuget:?package=TenantCore.EntityFramework.PostgreSql&version=2.3.0
#tool nuget:?package=TenantCore.EntityFramework.PostgreSql&version=2.3.0
TenantCore.EntityFramework
A robust, extensible multi-tenancy solution for Entity Framework Core with schema-per-tenant isolation on PostgreSQL.
Features
- 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
- Extensible Architecture: Add custom strategies, resolvers, and seeders
Quick Start
Prerequisites
- .NET 8.0+ SDK
- PostgreSQL 12+ (running and accessible)
- A PostgreSQL connection string (e.g.,
Host=localhost;Database=myapp;Username=postgres;Password=secret)
Installation
dotnet add package TenantCore.EntityFramework
dotnet add package TenantCore.EntityFramework.PostgreSql
1. Create a Tenant-Aware DbContext
The 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.
2. Register Services
// 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>();
// 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.
3. Add Endpoints
// 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();
4. Create EF Core Migrations
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 AppDbContext
You 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.
Tenant Resolution
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 |
Header-Based (Recommended for APIs)
builder.Services.AddHeaderTenantResolver<string>("X-Tenant-Id");
Claims-Based (JWT/Authentication)
builder.Services.AddClaimsTenantResolver<string>("tenant_id");
Subdomain-Based
builder.Services.AddSubdomainTenantResolver<string>("example.com");
// tenant1.example.com -> tenant1
Query String-Based
builder.Services.AddScoped<ITenantResolver<string>>(sp =>
new QueryStringTenantResolver<string>(
sp.GetRequiredService<IHttpContextAccessor>(),
"tenant"));
// /api/products?tenant=tenant1 -> tenant1
Route Value-Based
builder.Services.AddScoped<ITenantResolver<string>>(sp =>
new RouteValueTenantResolver<string>(
sp.GetRequiredService<IHttpContextAccessor>(),
"tenantId"));
// /api/{tenantId}/products -> extracts from route
Path-Based
// 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 -> tenant
API Key-Based (Requires Control Database)
builder.Services.AddApiKeyTenantResolver<Guid>("X-Api-Key");
// Verifies tenant API key using salted PBKDF2-SHA256 hashing in the control database
// Only returns Active tenants
Custom Resolver
public 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>();
Shared Entities
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.
Migration Management
Automatic Migrations on Startup
options.ConfigureMigrations(migrations =>
{
migrations.ApplyOnStartup = true;
migrations.ParallelMigrations = 4;
migrations.Timeout = TimeSpan.FromMinutes(5);
});
Manual Migration
var tenantManager = serviceProvider.GetRequiredService<ITenantManager<string>>();
// Migrate specific tenant
await tenantManager.MigrateTenantAsync("tenant1");
// Migrate all tenants
await tenantManager.MigrateAllTenantsAsync();
Multiple DbContexts
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.
Define a Second DbContext
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)
{
}
}
Register with Per-Context Migration History Tables
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)
Creating Migrations for Multiple Contexts
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/Inventory
Migrating Multiple Contexts
Each 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.
Tenant Lifecycle
Provisioning
await tenantManager.ProvisionTenantAsync("new-tenant");
Archiving
await tenantManager.ArchiveTenantAsync("tenant-to-archive");
Restoring
await tenantManager.RestoreTenantAsync("archived-tenant");
Deletion
// Soft delete (renames schema)
await tenantManager.DeleteTenantAsync("tenant-id", hardDelete: false);
// Hard delete (drops schema)
await tenantManager.DeleteTenantAsync("tenant-id", hardDelete: true);
Tenant Scoping
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
}
}
}
Data Seeding
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>();
Events
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>();
Health Checks
builder.Services.AddTenantHealthChecks<AppDbContext, string>("tenants");
Control Database (Optional)
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
Setup
// 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];
});
Provisioning with Control Database
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 Active
Custom Tenant Store (BYO)
Implement your own tenant storage by implementing ITenantStore:
public class MyTenantStore : ITenantStore
{
// Implement all ITenantStore methods
}
// Register
builder.Services.AddTenantStore<MyTenantStore>(options =>
{
options.EnableCaching = true;
});
Tenant Record Fields
| 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 |
Configuration Options
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");
});
Separate Migrations Assembly
When your migrations are in a separate assembly (common in clean architecture):
builder.Services.AddTenantDbContextPostgreSql<AppDbContext, string>(
connectionString,
migrationsAssembly: "MyApp.Infrastructure");
Supported Databases
| Database | Package | Status |
|---|---|---|
| PostgreSQL | TenantCore.EntityFramework.PostgreSql | Supported |
| SQL Server | TenantCore.EntityFramework.SqlServer | Planned |
| MySQL | TenantCore.EntityFramework.MySql | Planned |
Sample Project
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 run
To enable the Control Database feature in the sample:
dotnet run -- --TenantCore:UseControlDatabase=true
Requirements
- .NET 8.0 or .NET 10.0
- Entity Framework Core 8.x or 10.x
- PostgreSQL 12+ (for PostgreSQL provider)
Troubleshooting
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.
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.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Contributing
Contributions are welcome! Please open an issue or submit a pull request on GitHub.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 8.0.11)
- TenantCore.EntityFramework (>= 2.3.0)
-
net8.0
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 8.0.11)
- TenantCore.EntityFramework (>= 2.3.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v2.2.0: Documentation updates and accuracy improvements