EF Core Plugins: Owning Migrations with ExcludeFromMigrations

EF Core Plugins: Owning Migrations with ExcludeFromMigrations

In Part 1, I showed how EF Core’s entity discovery pulls plugin tables into the host migration. The fix is ExcludeFromMigrations(), introduced in EF Core 5.0 and stable through EF Core 10. But the method name is slightly misleading: it does not remove the entity from the model. It removes migration ownership of the table. EF Core still knows the entity exists, still lets you query it, and still tracks changes. It just stops generating CREATE, ALTER, and DROP statements for that table in the current context’s migrations.

That distinction matters a lot. You keep the full EF Core query surface while ceding schema control back to whoever actually owns the table.

What ExcludeFromMigrations Does and Does Not Do

The API lives on TableBuilder<TEntity>:

modelBuilder.Entity<Post>()
    .ToTable("Posts", schema: "blog", t => t.ExcludeFromMigrations());

After this call:

  • Migrations generated from this DbContext will not include any DDL (data definition language) for blog.Posts
  • dbContext.Set<Post>() still works; queries translate to SQL normally
  • Change tracking, SaveChanges, raw SQL: all intact
  • EF Core’s model snapshot records the entity, so subsequent migrations do not flip-flop about whether it exists

Two version notes worth keeping in mind. In EF Core 7, passing null as the table name throws ArgumentNullException: the name is mandatory. In EF Core 8, a TPC (table-per-concrete-type) hierarchy bug was fixed: previously, excluding one table in a TPC inheritance hierarchy accidentally excluded all sibling tables. Since 8.0, only the specific table you target is excluded.

The FK Navigation Gotcha

Here is where developers hit the wall. EF Core GitHub issue #23639 describes the behavior: when the host DbContext has a configured navigation property pointing at an excluded entity, EF Core regenerates DROP FOREIGN KEY and ADD FOREIGN KEY operations on every subsequent migration, even when nothing has changed. The migration diff incorrectly thinks the FK (foreign key) needs recreation.

The fix is unambiguous: do not configure navigation properties across context boundaries. The host context should reference plugin entities by foreign key column value only. The entity type stays in the model for queries, but no relationship is configured.

// Bad: navigation property across context boundary
public class Page
{
    public Guid Id { get; set; }
    public ICollection<Post> RelatedPosts { get; set; } = [];  // do not do this
}

// Correct: FK column value only
public class Page
{
    public Guid Id { get; set; }
    // No navigation. Reference Post.PageId from the application layer.
}

If you need to load related data, do it in the application layer as a separate query. More on that shortly.

Layer 1: Host Context Excludes Plugin Tables

The host DbContext can still know about plugin entities for raw queries, reporting joins, and dashboard aggregates. It just does not own the tables:

public class CmsDbContext(DbContextOptions<CmsDbContext> options) : DbContext(options)
{
    public DbSet<Page> Pages => Set<Page>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("cms");

        // Plugin entity: queryable, not migration-managed, no navigation configured
        modelBuilder.Entity<Post>()
            .ToTable("Posts", schema: "blog", t => t.ExcludeFromMigrations());
    }
}

Now dotnet ef migrations add on the host project generates DDL only for cms.* tables. The blog.Posts table appears in the EF model snapshot but produces no migration output.

Layer 2: Plugin Context Owns Its Tables

The plugin ships its own DbContext with full ownership of its schema. No ExcludeFromMigrations needed here. This context is the authoritative owner:

public class BlogPluginDbContext(DbContextOptions<BlogPluginDbContext> options)
    : DbContext(options)
{
    public DbSet<Post> Posts => Set<Post>();
    public DbSet<Tag> Tags => Set<Tag>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("blog");

        modelBuilder.Entity<Post>(e =>
        {
            e.HasKey(p => p.Id);
            // FK by value only; no navigation to CmsUser from the host context
            e.Property(p => p.AuthorId);
        });
    }
}

The HasDefaultSchema("blog") call is critical. Without it, table names collide with the host schema when both use SQL Server’s dbo default. Each context needs its own schema to prevent ambiguity in both naming and migration isolation.

Migration Isolation via IDesignTimeDbContextFactory

For dotnet ef migrations add to work from the plugin project at design time, the plugin needs an IDesignTimeDbContextFactory. This tells the EF tooling how to instantiate the context independently of the host DI container:

public class BlogPluginDbContextFactory : IDesignTimeDbContextFactory<BlogPluginDbContext>
{
    public BlogPluginDbContext CreateDbContext(string[] args)
    {
        var options = new DbContextOptionsBuilder<BlogPluginDbContext>()
            .UseSqlServer(
                "Server=.;Database=CmsDb;Trusted_Connection=True;",
                x => x.MigrationsAssembly(
                           typeof(BlogPluginDbContextFactory).Assembly.GetName().Name!)
                       .MigrationsHistoryTable("__BlogMigrations", "blog"))
            .Options;

        return new BlogPluginDbContext(options);
    }
}

The MigrationsHistoryTable call is not optional. By default, every EF context writes applied migrations to __EFMigrationsHistory in the default schema. If both the host and the plugin share a database, they collide on that table: the host sees plugin migration entries and vice versa, producing false “already applied” errors on deployment. Isolating the history table per context keeps each migration ledger clean and independent.

Plugin migrations run from the plugin project:

dotnet ef migrations add InitialBlogSchema --context BlogPluginDbContext `
  --project BlogPlugin.Migrations

Host migrations run from the host project:

dotnet ef migrations add InitialCms --context CmsDbContext `
  --project CmsHost

Independent timelines, independent tooling, independent history tables.

Host Registration

When registering both contexts at startup, the host needs the plugin’s migration assembly and history table:

builder.Services.AddDbContext<CmsDbContext>(options =>
    options.UseSqlServer(connectionString,
        x => x.MigrationsHistoryTable("__CmsMigrations", "cms")));

builder.Services.AddDbContext<BlogPluginDbContext>(options =>
    options.UseSqlServer(connectionString,
        x => x.MigrationsAssembly("BlogPlugin.Migrations")
               .MigrationsHistoryTable("__BlogMigrations", "blog")));

Layer 3: Cross-Context Data in the Application Layer

With navigation properties removed across context boundaries, loading related data requires two queries. This is an explicit data access pattern that makes the context boundary visible in code, which is actually a feature rather than a limitation:

public async Task<PostWithAuthorDto> GetPostWithAuthorAsync(Guid postId)
{
    var post = await _blogDb.Posts.FindAsync(postId);
    var author = await _cmsDb.Users.FindAsync(post!.AuthorId);
    return new PostWithAuthorDto(post, author);
}

For reporting scenarios where you need JOIN-like semantics, use the host context’s excluded entity reference directly in LINQ. EF Core generates the SQL JOIN even without a navigation property, as long as both tables are accessible on the same connection:

var results = await _cmsDb.Set<Post>()
    .Where(p => p.PublishedAt > DateTime.UtcNow.AddDays(-30))
    .Join(_cmsDb.Pages,
          post => post.PageId,
          page => page.Id,
          (post, page) => new { post.Title, page.Slug })
    .ToListAsync();

This works because Post is still in the CmsDbContext model via ExcludeFromMigrations. EF Core can generate SQL for it; it just will not generate DDL for it.

Startup Migration Ordering

When applying migrations at startup, order matters. Host tables often serve as the parent side of foreign keys that plugins reference. Apply the host first:

using var scope = app.Services.CreateScope();

// Host first: cms.Users, cms.Pages, etc. must exist before plugin FKs resolve
await scope.ServiceProvider
    .GetRequiredService<CmsDbContext>()
    .Database.MigrateAsync();

// Plugin second: blog.Posts may FK into cms.Users
await scope.ServiceProvider
    .GetRequiredService<BlogPluginDbContext>()
    .Database.MigrateAsync();

For plugin seed data, prefer EF Core 9’s UseSeeding over HasData. HasData embeds seed records in migrations, which creates timeline ordering conflicts between contexts when they share a database. UseSeeding runs after MigrateAsync and is decoupled from migration history entirely:

services.AddDbContext<BlogPluginDbContext>(options =>
    options
        .UseSqlServer(connectionString)
        .UseSeeding((context, _) =>
        {
            if (!context.Set<BlogCategory>().Any())
                context.Set<BlogCategory>().Add(new BlogCategory { Name = "General" });
            context.SaveChanges();
        }));

Integration Testing

With each context owning its own migration history, testing follows the same separation. The host context and plugin context apply their migrations independently in test setup:

public class BlogPluginTests : IAsyncLifetime
{
    private readonly MsSqlContainer _db = new MsSqlBuilder().Build();

    public async Task InitializeAsync()
    {
        await _db.StartAsync();
        var connStr = _db.GetConnectionString();

        // Host tables must exist first (plugin may FK into them)
        await using var cmsCtx = CreateCmsContext(connStr);
        await cmsCtx.Database.MigrateAsync();

        // Plugin applies its own migrations independently
        await using var blogCtx = CreateBlogContext(connStr);
        await blogCtx.Database.MigrateAsync();
    }

    public Task DisposeAsync() => _db.DisposeAsync().AsTask();
}

For plugin-only tests where the host context is not relevant, you can skip CmsDbContext.MigrateAsync() entirely, but you will need to manually create any tables the plugin’s ExcludeFromMigrations entities reference, since those tables will not exist. A minimal DbContext that only maps what the test needs is the cleanest approach for fully isolated plugin testing.

When to Use This Pattern vs. a Unified Context

The alternative is a unified context: one DbContext that calls each module’s OnModelCreating registration method, merging all modules into one migration timeline. One dotnet ef migrations add covers everything, and startup ordering concerns disappear.

The cost is coupling. A broken model configuration in one module blocks migrations for every other module. Teams cannot evolve schemas independently. Removing a module requires surgery on the shared migration history. You cannot just uninstall a plugin and move on.

The ExcludeFromMigrations pattern prioritizes plugin autonomy over operational simplicity. Choose it when:

  • Plugins need to evolve schemas on their own timeline
  • Plugin installation and removal must be self-contained operations
  • Plugin authors should not need to coordinate schema changes with the host team
  • Third-party or community plugins are part of the design

Choose a unified context when:

  • You control all modules and cross-team coordination is not a constraint
  • Deployment simplicity outweighs module independence
  • The module set is stable and schema changes are infrequent

The decision maps to organizational structure and deployment philosophy more than technical correctness.

The Complete Picture

ExcludeFromMigrations() is precise. It removes DDL ownership without touching query capability. The full pattern is:

  • Host context: ExcludeFromMigrations() for each plugin entity, no navigation properties across context boundaries
  • Plugin context: owns its schema with HasDefaultSchema, separate migrations assembly, isolated MigrationsHistoryTable
  • Design-time tooling: IDesignTimeDbContextFactory per plugin context for independent dotnet ef commands
  • Application layer: explicit multi-context queries instead of cross-context navigations
  • Startup: host migrations before plugin migrations

That is a foundation for plugin-based systems that can evolve their schemas independently, install and uninstall cleanly, and stay decoupled from the host’s migration timeline.

Comments

VG Wort