TenantCore.EntityFramework.PostgreSql 2.3.0

There is a newer version of this package available.
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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TenantCore.EntityFramework.PostgreSql" Version="2.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TenantCore.EntityFramework.PostgreSql" Version="2.3.0" />
                    
Directory.Packages.props
<PackageReference Include="TenantCore.EntityFramework.PostgreSql" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TenantCore.EntityFramework.PostgreSql --version 2.3.0
                    
#r "nuget: TenantCore.EntityFramework.PostgreSql, 2.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TenantCore.EntityFramework.PostgreSql@2.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TenantCore.EntityFramework.PostgreSql&version=2.3.0
                    
Install as a Cake Addin
#tool nuget:?package=TenantCore.EntityFramework.PostgreSql&version=2.3.0
                    
Install as a Cake Tool

TenantCore.EntityFramework

A robust, extensible multi-tenancy solution for Entity Framework Core with schema-per-tenant isolation on PostgreSQL.

License: MIT NuGet .NET

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: ExcludePaths is 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
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.ProvisionTenantAsync only applies migrations for the primary context (the first one registered). For additional contexts, you must explicitly call their TenantMigrationRunner<TContext, TKey>.MigrateTenantAsync during provisioning.

Note: If you only have a single DbContext, you don't need to specify migrationHistoryTable. The default __EFMigrationsHistory table 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.4.0 0 2/12/2026
2.3.0 23 2/11/2026
2.2.0 73 2/6/2026
2.0.0 89 2/5/2026
1.1.1 72 2/5/2026
1.1.0 66 2/5/2026
1.0.0 69 2/5/2026

v2.2.0: Documentation updates and accuracy improvements