Skip to content
Kumar Chandrachooda
.NET

Making EF Core Migrations Schema-Aware

EF Core wants one database, one model and one migrations history table. Schema-per-tenant breaks all three assumptions. Constructor-injected migrations, a per-schema history table and a replaced IMigrationsAssembly put one migration set in charge of every tenant schema.

By Kumar Chandrachooda 04 Aug 2025 5 min read
One migration, every schema, its own ledger

EF Core migrations are built on three quiet assumptions: there is one database, it holds one model, and one __EFMigrationsHistory table records what has been applied. Schema-per-tenant violates all three at once. The same migration must run against tenant_a1b2c3d4 today and tenant_9f8e7d6c tomorrow, each schema needs its own record of what has been applied, and the model itself — table schemas, index names, foreign keys — changes identity depending on which tenant you ask for.

This is the part of the series I actually built the platform to explore. Part 2 ended with a request that knows its schema; this part makes EF Core act on it.

A context that takes its schema as a constructor argument

Everything hangs off one decision: the schema is a constructor parameter of the DbContext, not ambient state:

public class SchemaAwareDbContext : DbContext, ISchemaAwareContext
{
    public const string DefaultSchema = "public";

    private readonly string _schemaName;

    public SchemaAwareDbContext(DbContextOptions<SchemaAwareDbContext> options,
        string schemaName = DefaultSchema) : base(options)
    {
        _schemaName = schemaName;
    }

    public string Schema => _schemaName;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema(_schemaName);
        ConfigurationFactory.ApplyConfigurationsWithSchema(
            modelBuilder, typeof(SchemaAwareDbContext).Assembly, _schemaName);
        base.OnModelCreating(modelBuilder);
    }
}

ISchemaAwareContext is a one-property interface — string Schema { get; } — and it looks almost too trivial to bother with. It is load-bearing: it is how the migration machinery later detects “this context has a dynamic schema” without referencing the concrete context type.

A factory stamps out instances per (connection string, schema) pair, and — critically — points the migrations history at the tenant's own schema:

optionsBuilder.UseNpgsql(connectionString, options =>
{
    options.MigrationsAssembly(typeof(InStoreAssembly).Assembly.GetName().Name);
    options.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
    options.EnableRetryOnFailure(3);
});

That MigrationsHistoryTable line is the difference between “a migration system” and “a migration system that can do partial rollouts”. With one history table per schema, tenant_a can be on migration 2 while tenant_b is still on migration 1, and MigrateAsync() against each schema independently knows what remains. Fleet rollouts (part 8) become resumable for free.

The problem with scaffolded migrations

Run dotnet ef migrations add against a context like this and you get a migration with the schema baked in as a string literal — whatever schema the design-time context happened to have. That migration will provision one tenant correctly and every other tenant wrongly.

So the migrations in this platform are hand-written, and they receive the schema the same way the context does — through the constructor:

public partial class CreateCrumbTable : SchemaAwareMigration
{
    private readonly ISchemaAwareContext _context;

    public CreateCrumbTable(ISchemaAwareContext context)
    {
        _context = context;
    }

    protected override void Up(MigrationBuilder migrationBuilder)
    {
        var schemaName = _context.Schema;

        migrationBuilder.CreateTable(
            name: "crumb",
            schema: schemaName,
            columns: table => new
            {
                id = table.Column<Guid>(type: "uuid", nullable: false,
                    defaultValueSql: "gen_random_uuid()"),
                parent_id = table.Column<Guid>(type: "uuid", nullable: true),
                correlation_id = table.Column<string>(type: "ltree", nullable: false),
                path = table.Column<string>(type: "ltree", nullable: false),
                crumb_data = table.Column<string>(type: "jsonb", nullable: true),
                // ...
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_crumb", x => x.id);
                table.ForeignKey(
                    name: "FK_crumb_crumb_parent_id",
                    column: x => x.parent_id,
                    principalTable: "crumb",
                    principalSchema: schemaName,
                    principalColumn: "id",
                    onDelete: ReferentialAction.Restrict);
            });

        migrationBuilder.CreateIndex(
                name: "IX_crumb_path", table: "crumb",
                schema: schemaName, column: "path")
            .Annotation("Npgsql:IndexMethod", "gist");
    }
}

Every operation — table, index, even the self-referencing foreign key's principalSchema — takes schemaName from the injected context. The same compiled class provisions any schema it is handed.

The migration IDs are hand-picked too (20241220000001_InitialLtreeExtension, 20241220000002_CreateCrumbTable). Since scaffolding is out of the loop, nothing generates timestamps for me; choosing sortable, deliberately-spaced IDs keeps ordering explicit and leaves room to slot a migration between two existing ones if it ever comes to that. It came to that exactly once, which is once more than I expected.

Teaching EF to construct the migration

There is a catch: I never construct migration classes. EF does, deep inside its migrator, and it expects a parameterless constructor. The component that does the instantiating is IMigrationsAssembly, and it is replaceable:

#pragma warning disable EF1001
public class InStoreAssembly(
    ICurrentDbContext currentContext,
    IDbContextOptions options,
    IMigrationsIdGenerator idGenerator,
    IDiagnosticsLogger<DbLoggerCategory.Migrations> logger)
    : MigrationsAssembly(currentContext, options, idGenerator, logger)
{
    private readonly DbContext _context = currentContext.Context;

    public override Migration CreateMigration(TypeInfo migrationClass, string activeProvider)
    {
        bool isSchemaAware =
            migrationClass.GetConstructor(new[] { typeof(ISchemaAwareContext) }) != null;

        if (isSchemaAware && _context is ISchemaAwareContext schemaAwareContext)
        {
            var migration = (Migration?)Activator.CreateInstance(
                migrationClass.AsType(), schemaAwareContext);

            if (migration != null)
            {
                migration.ActiveProvider = activeProvider;
                return migration;
            }
        }

        return base.CreateMigration(migrationClass, activeProvider);
    }
}

The logic is small and honest: if the migration class has a constructor taking ISchemaAwareContext, hand it the live context — which knows its schema — otherwise fall back to stock behaviour, so ordinary migrations coexist with schema-aware ones. Wiring it in is one line in OnConfiguring:

optionsBuilder.ReplaceService<IMigrationsAssembly, InStoreAssembly>();

I did not invent this technique. The code comments credit a pattern I found in an open-source project referred to as the chocolat-shop pattern, and I kept the name in the source as an audit trail of where the idea came from. Good artists borrow; tired platform engineers cite.

There is also an abstract SchemaAwareMigration base class that the migrations derive from. Most of what it offers is convenience — a CreateIndexWithSchema helper that attaches the Npgsql:IndexMethod annotation for GiST/GIN indexes, a service-provider hook so a migration can trigger schema operations beyond its own DDL, and a GetSchemaName() fallback chain that consults a DynamicMigrationContext before giving up and returning public. In practice the constructor-injection path above carries all the real traffic; the base class is belt-and-braces for migrations that need to act on the schema rather than just target it. I keep it because the first migration in the set (InitialLtreeExtension) has no schema-specific DDL at all — it installs a database-scoped extension — and having one common ancestor for both kinds keeps the migration folder self-describing.

That #pragma warning disable EF1001 is not decoration. MigrationsAssembly is EF Core internal API: its constructor signature has changed between major versions and can change again. The pragma is me accepting a maintenance contract — every EF upgrade, this class gets recompiled, retested, and possibly rewritten. I consider the price fair for what it buys, but it is a real line item.

What this costs

Scaffolding is gone. dotnet ef migrations add still runs, but its output is a starting point to be rewritten by hand — every schema literal replaced with _context.Schema, the designer file's snapshot checked. The team convention becomes “migrations are code you write, not code you generate”, which is slower and, honestly, better for review discipline.

The model snapshot drifts. Because migrations are hand-edited, EF's snapshot no longer perfectly matches what the migrations produce, and EF 9 flags it (PendingModelChangesWarning). The context suppresses that warning deliberately — with the trade-off that a genuine forgotten migration is also silenced. Part 4 digs into why the model layer needs even more persuasion.

Internal APIs bite on upgrade. Covered above; budget for it.

What you get in exchange is the thing no built-in mechanism offers: one compiled migration set, N schemas, each with its own history, each independently migratable — the primitive every later chapter of this series builds on.

And yet, with everything on this page in place, my second tenant still refused to migrate while the first one worked perfectly. The culprit was a cache I did not know existed — that is part 4.