EF Core Plugins: When Migrations Go Wrong

EF Core Plugins: When Migrations Go Wrong

Picture this: your ASP.NET Core application is well-structured, extensible by design. A blog plugin ships its own entities, its own business logic. The host application references the plugin assembly for navigation purposes and everything compiles cleanly. Then you run dotnet ef migrations add InitialMigration from the host project, and the generated migration cheerfully creates blog.Posts, blog.Tags, and every other table the plugin owns. The host just annexed your plugin’s schema.

This is not a contrived edge case. It happens in modular monoliths, SaaS platforms with optional feature modules, content management systems, and any extensible host that shares a database with its plugins. The root cause is mundane: EF Core is doing exactly what it was designed to do. The problem is that nobody told it where the host’s responsibility ends.

How EF Core Discovers Entities

When OnModelCreating runs, EF Core does not stop at the types you explicitly registered via DbSet<T> properties. It traverses navigation properties. If your host DbContext has a DbSet<Page>, and Page has a navigation property to a Post (which belongs to the blog plugin), EF Core follows that relationship and includes Post in the model. From EF Core’s perspective, it found an entity type, and it adds it to the migration.

This behavior is intentional and generally useful. In a straightforward application with a single context, you want EF Core to discover related entities automatically. But in a plugin architecture, this auto-discovery crosses a boundary it should not cross.

The same thing happens when a host DbContext has an explicit modelBuilder.Entity<Post>() call (perhaps to configure a query filter or join behavior) without any instruction about who owns the table. EF Core registers it, includes it in migrations, and now the host migration file contains CREATE TABLE [blog].[Posts].

This rarely shows up on day one. The trigger is typically a later refactor: someone adds a Post? LatestPost navigation property to Page so the dashboard can show a preview. Nobody touches OnModelCreating, and yet the next dotnet ef migrations add silently pulls in the entire Post graph, which is why it survives code review.

Entity Configuration Scanning

The discovery problem compounds when you use ApplyConfigurationsFromAssembly. This method scans an assembly for all classes implementing IEntityTypeConfiguration<T> and applies them automatically. When the host scans a plugin assembly to load query configurations or read model conventions, it pulls in every entity configuration the plugin defines, regardless of whether those configurations were intended for the plugin’s own context.

// Looks tidy. Silently imports every plugin entity configuration.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(BlogPlugin).Assembly);
}

Every IEntityTypeConfiguration<Post>, IEntityTypeConfiguration<Tag>, and IEntityTypeConfiguration<BlogCategory> in the plugin assembly gets applied. The host migration now owns every table those configurations describe. EF Core does not distinguish between configurations intended for the plugin’s own context and configurations that happen to live in the same assembly. From the model builder’s perspective, they are all equally valid type registrations.

The Three Failure Modes

Once the host migration owns plugin tables, you are dealing with three distinct failure modes, each painful in its own way.

Duplicate schema creation. The plugin ships its own DbContext with its own migrations. When the plugin’s migrations run, they attempt to create blog.Posts. The host already created it. The migration fails with a “table already exists” error, or worse, silently produces inconsistent state depending on how your deployment pipeline handles idempotency.

Schema drift. The plugin’s team adds a column to Post. They create a plugin migration. The host knows nothing about this column. It has its own snapshot, its own view of Post from whenever it first discovered the entity. The host migration model and the plugin migration model diverge. Now adding any unrelated host migration produces incorrect diff output, and the plugin schema change is either duplicated or lost depending on which context runs first.

Orphaned migration history. The customer uninstalls the blog plugin. From a database perspective, the plugin tables should be removable. But those CREATE TABLE statements live in the host’s migration history. You cannot cleanly roll back the plugin tables without touching the host migration timeline. And if you do touch the host migration timeline in a running production system, you risk breaking applied migration records for every deployed instance. The next deploy after an uninstall attempt is where this surfaces: the pipeline reapplies host migrations, expects blog.Posts to exist because the snapshot says so, and either fails outright or silently recreates a table the customer just removed.

These three failure modes share a root cause: the host migration timeline is coupled to the plugin’s schema. Every change the plugin needs to make requires either coordination with the host migration sequence, or a workaround that increases complexity with every iteration.

A Concrete Look at the Bad Migration

Here is what a contaminated host migration looks like. Assume the host has a CmsDbContext with a DbSet<Page>. The plugin has a Post entity with an AuthorId property. The host also exposes Post via a DbSet<Post> property for dashboard queries.

The Contaminated DbContext

public class CmsDbContext(DbContextOptions<CmsDbContext> options) : DbContext(options)
{
    public DbSet<Page> Pages => Set<Page>();
    // Exposed for cross-context dashboard queries
    public DbSet<Post> Posts => Set<Post>();

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

What EF Core Generates

Running dotnet ef migrations add InitialCms produces something like this in the generated migration:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.EnsureSchema(name: "cms");
    migrationBuilder.EnsureSchema(name: "blog");

    migrationBuilder.CreateTable(
        name: "Pages",
        schema: "cms",
        columns: table => new { /* ... */ });

    // The host has no business creating this table
    migrationBuilder.CreateTable(
        name: "Posts",
        schema: "blog",
        columns: table => new { /* ... */ });
}

The migration file reflects EF Core’s model snapshot. It does not know or care that Post logically belongs to the plugin. It just sees an entity type, and it creates the table.

The generated CmsDbContextModelSnapshot.cs is equally unambiguous about what it thinks it owns:

// CmsDbContextModelSnapshot.cs (generated by EF Core)
modelBuilder.Entity("BlogPlugin.Entities.Post", b =>
{
    b.Property<Guid>("Id").ValueGeneratedOnAdd();
    b.Property<string>("Title").IsRequired().HasMaxLength(500);
    b.Property<Guid>("AuthorId");
    b.Property<DateTime>("PublishedAt");
    b.HasKey("Id");
    b.ToTable("Posts", "blog"); // host snapshot permanently records this
});

This snapshot entry is what makes the problem persistent. Even if you remove DbSet<Post> from CmsDbContext tomorrow, EF Core will detect the discrepancy and offer to generate a DROP TABLE migration. The snapshot is the source of truth for what the host context “owns,” and once a plugin entity is in it, extraction requires deliberate intervention.

The Model Snapshot Trap

EF Core’s migration system works by diffing the current model against a stored snapshot. That snapshot lives in CmsDbContextModelSnapshot.cs next to your migration files, and EF Core generates and updates it automatically every time you run dotnet ef migrations add.

Once a plugin entity appears in a migration, it enters the snapshot. Removing the DbSet<Post> property from CmsDbContext does not clean the snapshot. When you run the next migration (even one completely unrelated to posts), EF Core diffs the new model (no Post) against the snapshot (has Post) and generates a DROP TABLE [blog].[Posts] statement. If that migration reaches production, you have deleted the plugin’s data.

The snapshot trap makes contamination sticky. You cannot simply stop referencing the plugin entity and move on. You have to either:

  1. Keep the entity in the model and accept permanent coupling to the plugin’s schema
  2. Surgically remove the entity from both the migration file and CmsDbContextModelSnapshot.cs, a manual edit that is easy to get wrong and leaves the history in an inconsistent state
  3. Use ExcludeFromMigrations() before the entity ever enters the snapshot, which prevents DDL ownership from being recorded in the first place

The third option is the only one that scales across multiple plugins and multiple schema versions. Once the snapshot contains a CREATE TABLE for a plugin table, you are already in recovery mode. The clean path is to never let it get there.

Why the Obvious Fixes Fall Short

The instinctive response is to reach for modelBuilder.Ignore<Post>() or the [NotMapped] attribute. Both work, in the sense that EF Core stops generating migrations for the entity. But they also strip EF Core’s awareness of the type entirely. You can no longer query Posts through the CmsDbContext. If your dashboard needs to join pages with posts, you have lost EF Core’s ability to help you do that.

Take the dashboard scenario from earlier as a concrete case. A “recent activity” widget needs pages joined with their latest posts, filtered by author and ordered by publish date. With Post mapped, that is one LINQ query. Call Ignore<Post>() and that query is gone: you are left writing raw SQL against blog.Posts, or standing up a second DbContext and stitching results together in memory. Both cost real maintenance effort for a problem unrelated to the dashboard feature.

HasNoKey() combined with .ToView("Posts", "blog") is closer: the entity becomes read-only and EF Core skips migration generation for views. But it changes the entity’s semantics to a view mapping, which is semantically wrong (it is a table), and it blocks insert/update/delete scenarios you might legitimately need.

What you actually want is a way to tell EF Core: “know about this entity, keep it in the model, let me query it, but do not touch its table in migrations.” That precise capability exists. It is a single method call, and it keeps the full EF Core query surface intact while handing schema ownership back to the plugin. That is the subject of Part 2 of this series.

Comments

VG Wort