{"authors":[{"name":"Martin Stühmer","url":"https://daily-devops.net/authors/martin/"},{"name":"Jendrik Brack","url":"https://daily-devops.net/authors/jendrik/"}],"description":"Recent content in EF Core: Migrations and Data Access Patterns on Daily DevOps \u0026 .NET","favicon":"https://daily-devops.net/images/logo_hu_6465d873dfa490cf.png","feed_url":"https://daily-devops.net/tags/efcore/feed.json","home_page_url":"https://daily-devops.net/tags/efcore/","icon":"https://daily-devops.net/images/logo_hu_5926de77762241ba.png","items":[{"authors":[{"name":"Martin Stühmer","url":"https://daily-devops.net/authors/martin/"}],"content_html":"\u003cp\u003ePicture 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 \u003ccode\u003edotnet ef migrations add InitialMigration\u003c/code\u003e from the host project, and the generated migration cheerfully creates \u003ccode\u003eblog.Posts\u003c/code\u003e, \u003ccode\u003eblog.Tags\u003c/code\u003e, and every other table the plugin owns. The host just annexed your plugin\u0026rsquo;s schema.\u003c/p\u003e\n\u003cp\u003eThis 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\u0026rsquo;s responsibility ends.\u003c/p\u003e\n\n\n\n\n\u003ch2 id=\"how-ef-core-discovers-entities\"\u003e\u003ca href=\"/posts/efcore-excludefrommigrations-1-the-problem/#how-ef-core-discovers-entities\" title=\"How EF Core Discovers Entities\"\u003eHow EF Core Discovers Entities\u003c/a\u003e\u003c/h2\u003e\n\u003cp\u003eWhen \u003ccode\u003eOnModelCreating\u003c/code\u003e runs, EF Core does not stop at the types you explicitly registered via \u003ccode\u003eDbSet\u0026lt;T\u0026gt;\u003c/code\u003e properties. It traverses navigation properties. If your host \u003ccode\u003eDbContext\u003c/code\u003e has a \u003ccode\u003eDbSet\u0026lt;Page\u0026gt;\u003c/code\u003e, and \u003ccode\u003ePage\u003c/code\u003e has a navigation property to a \u003ccode\u003ePost\u003c/code\u003e (which belongs to the blog plugin), EF Core follows that relationship and includes \u003ccode\u003ePost\u003c/code\u003e in the model. From EF Core\u0026rsquo;s perspective, it found an entity type, and it adds it to the migration.\u003c/p\u003e\n\u003cp\u003eThis 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.\u003c/p\u003e\n\u003cp\u003eThe same thing happens when a host \u003ccode\u003eDbContext\u003c/code\u003e has an explicit \u003ccode\u003emodelBuilder.Entity\u0026lt;Post\u0026gt;()\u003c/code\u003e 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 \u003ccode\u003eCREATE TABLE [blog].[Posts]\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eThis rarely shows up on day one. The trigger is typically a later refactor: someone adds a \u003ccode\u003ePost? LatestPost\u003c/code\u003e navigation property to \u003ccode\u003ePage\u003c/code\u003e so the dashboard can show a preview. Nobody touches \u003ccode\u003eOnModelCreating\u003c/code\u003e, and yet the next \u003ccode\u003edotnet ef migrations add\u003c/code\u003e silently pulls in the entire \u003ccode\u003ePost\u003c/code\u003e graph, which is why it survives code review.\u003c/p\u003e\n\n\n\n\n\u003ch3 id=\"entity-configuration-scanning\"\u003e\u003ca href=\"/posts/efcore-excludefrommigrations-1-the-problem/#entity-configuration-scanning\" title=\"Entity Configuration Scanning\"\u003eEntity Configuration Scanning\u003c/a\u003e\u003c/h3\u003e\n\u003cp\u003eThe discovery problem compounds when you use \u003ccode\u003eApplyConfigurationsFromAssembly\u003c/code\u003e. This method scans an assembly for all classes implementing \u003ccode\u003eIEntityTypeConfiguration\u0026lt;T\u0026gt;\u003c/code\u003e 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\u0026rsquo;s own context.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-csharp\" data-lang=\"csharp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// Looks tidy. Silently imports every plugin entity configuration.\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003eprotected\u003c/span\u003e \u003cspan class=\"kd\"\u003eoverride\u003c/span\u003e \u003cspan class=\"k\"\u003evoid\u003c/span\u003e \u003cspan class=\"n\"\u003eOnModelCreating\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eModelBuilder\u003c/span\u003e \u003cspan class=\"n\"\u003emodelBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003emodelBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eApplyConfigurationsFromAssembly\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003etypeof\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eBlogPlugin\u003c/span\u003e\u003cspan class=\"p\"\u003e).\u003c/span\u003e\u003cspan class=\"n\"\u003eAssembly\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eEvery \u003ccode\u003eIEntityTypeConfiguration\u0026lt;Post\u0026gt;\u003c/code\u003e, \u003ccode\u003eIEntityTypeConfiguration\u0026lt;Tag\u0026gt;\u003c/code\u003e, and \u003ccode\u003eIEntityTypeConfiguration\u0026lt;BlogCategory\u0026gt;\u003c/code\u003e 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\u0026rsquo;s own context and configurations that happen to live in the same assembly. From the model builder\u0026rsquo;s perspective, they are all equally valid type registrations.\u003c/p\u003e\n\n\n\n\n\u003ch2 id=\"the-three-failure-modes\"\u003e\u003ca href=\"/posts/efcore-excludefrommigrations-1-the-problem/#the-three-failure-modes\" title=\"The Three Failure Modes\"\u003eThe Three Failure Modes\u003c/a\u003e\u003c/h2\u003e\n\u003cp\u003eOnce the host migration owns plugin tables, you are dealing with three distinct failure modes, each painful in its own way.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDuplicate schema creation.\u003c/strong\u003e The plugin ships its own \u003ccode\u003eDbContext\u003c/code\u003e with its own migrations. When the plugin\u0026rsquo;s migrations run, they attempt to create \u003ccode\u003eblog.Posts\u003c/code\u003e. The host already created it. The migration fails with a \u0026ldquo;table already exists\u0026rdquo; error, or worse, silently produces inconsistent state depending on how your deployment pipeline handles idempotency.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eSchema drift.\u003c/strong\u003e The plugin\u0026rsquo;s team adds a column to \u003ccode\u003ePost\u003c/code\u003e. They create a plugin migration. The host knows nothing about this column. It has its own snapshot, its own view of \u003ccode\u003ePost\u003c/code\u003e 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.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eOrphaned migration history.\u003c/strong\u003e The customer uninstalls the blog plugin. From a database perspective, the plugin tables should be removable. But those \u003ccode\u003eCREATE TABLE\u003c/code\u003e statements live in the host\u0026rsquo;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 \u003ccode\u003eblog.Posts\u003c/code\u003e to exist because the snapshot says so, and either fails outright or silently recreates a table the customer just removed.\u003c/p\u003e\n\u003cp\u003eThese three failure modes share a root cause: \u003cstrong\u003ethe host migration timeline is coupled to the plugin\u0026rsquo;s schema\u003c/strong\u003e. Every change the plugin needs to make requires either coordination with the host migration sequence, or a workaround that increases complexity with every iteration.\u003c/p\u003e\n\n\n\n\n\u003ch2 id=\"a-concrete-look-at-the-bad-migration\"\u003e\u003ca href=\"/posts/efcore-excludefrommigrations-1-the-problem/#a-concrete-look-at-the-bad-migration\" title=\"A Concrete Look at the Bad Migration\"\u003eA Concrete Look at the Bad Migration\u003c/a\u003e\u003c/h2\u003e\n\u003cp\u003eHere is what a contaminated host migration looks like. Assume the host has a \u003ccode\u003eCmsDbContext\u003c/code\u003e with a \u003ccode\u003eDbSet\u0026lt;Page\u0026gt;\u003c/code\u003e. The plugin has a \u003ccode\u003ePost\u003c/code\u003e entity with an \u003ccode\u003eAuthorId\u003c/code\u003e property. The host also exposes \u003ccode\u003ePost\u003c/code\u003e via a \u003ccode\u003eDbSet\u0026lt;Post\u0026gt;\u003c/code\u003e property for dashboard queries.\u003c/p\u003e\n\n\n\n\n\u003ch3 id=\"the-contaminated-dbcontext\"\u003e\u003ca href=\"/posts/efcore-excludefrommigrations-1-the-problem/#the-contaminated-dbcontext\" title=\"The Contaminated DbContext\"\u003eThe Contaminated DbContext\u003c/a\u003e\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-csharp\" data-lang=\"csharp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eCmsDbContext\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eDbContextOptions\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eCmsDbContext\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003eoptions\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eDbContext\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eoptions\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"n\"\u003eDbSet\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003ePage\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003ePages\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003eSet\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003ePage\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// Exposed for cross-context dashboard queries\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"n\"\u003eDbSet\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003ePost\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003ePosts\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003eSet\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003ePost\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"kd\"\u003eprotected\u003c/span\u003e \u003cspan class=\"kd\"\u003eoverride\u003c/span\u003e \u003cspan class=\"k\"\u003evoid\u003c/span\u003e \u003cspan class=\"n\"\u003eOnModelCreating\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eModelBuilder\u003c/span\u003e \u003cspan class=\"n\"\u003emodelBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003emodelBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eHasDefaultSchema\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;cms\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\n\n\n\u003ch3 id=\"what-ef-core-generates\"\u003e\u003ca href=\"/posts/efcore-excludefrommigrations-1-the-problem/#what-ef-core-generates\" title=\"What EF Core Generates\"\u003eWhat EF Core Generates\u003c/a\u003e\u003c/h3\u003e\n\u003cp\u003eRunning \u003ccode\u003edotnet ef migrations add InitialCms\u003c/code\u003e produces something like this in the generated migration:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-csharp\" data-lang=\"csharp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kd\"\u003eprotected\u003c/span\u003e \u003cspan class=\"kd\"\u003eoverride\u003c/span\u003e \u003cspan class=\"k\"\u003evoid\u003c/span\u003e \u003cspan class=\"n\"\u003eUp\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eMigrationBuilder\u003c/span\u003e \u003cspan class=\"n\"\u003emigrationBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003emigrationBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eEnsureSchema\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;cms\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003emigrationBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eEnsureSchema\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;blog\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003emigrationBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eCreateTable\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;Pages\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003eschema\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;cms\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003ecolumns\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003etable\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"k\"\u003enew\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"cm\"\u003e/* ... */\u003c/span\u003e \u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// The host has no business creating this table\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003emigrationBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eCreateTable\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;Posts\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003eschema\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;blog\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"n\"\u003ecolumns\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003etable\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"k\"\u003enew\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"cm\"\u003e/* ... */\u003c/span\u003e \u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe migration file reflects EF Core\u0026rsquo;s model snapshot. It does not know or care that \u003ccode\u003ePost\u003c/code\u003e logically belongs to the plugin. It just sees an entity type, and it creates the table.\u003c/p\u003e\n\u003cp\u003eThe generated \u003ccode\u003eCmsDbContextModelSnapshot.cs\u003c/code\u003e is equally unambiguous about what it thinks it owns:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-csharp\" data-lang=\"csharp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// CmsDbContextModelSnapshot.cs (generated by EF Core)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003emodelBuilder\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eEntity\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;BlogPlugin.Entities.Post\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eProperty\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eGuid\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;Id\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e).\u003c/span\u003e\u003cspan class=\"n\"\u003eValueGeneratedOnAdd\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eProperty\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"kt\"\u003estring\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;Title\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e).\u003c/span\u003e\u003cspan class=\"n\"\u003eIsRequired\u003c/span\u003e\u003cspan class=\"p\"\u003e().\u003c/span\u003e\u003cspan class=\"n\"\u003eHasMaxLength\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"m\"\u003e500\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eProperty\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eGuid\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;AuthorId\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eProperty\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eDateTime\u003c/span\u003e\u003cspan class=\"p\"\u003e\u0026gt;(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;PublishedAt\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eHasKey\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;Id\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eToTable\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\u0026#34;Posts\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\u0026#34;blog\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e \u003cspan class=\"c1\"\u003e// host snapshot permanently records this\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThis snapshot entry is what makes the problem persistent. Even if you remove \u003ccode\u003eDbSet\u0026lt;Post\u0026gt;\u003c/code\u003e from \u003ccode\u003eCmsDbContext\u003c/code\u003e 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 \u0026ldquo;owns,\u0026rdquo; and once a plugin entity is in it, extraction requires deliberate intervention.\u003c/p\u003e\n\n\n\n\n\u003ch2 id=\"the-model-snapshot-trap\"\u003e\u003ca href=\"/posts/efcore-excludefrommigrations-1-the-problem/#the-model-snapshot-trap\" title=\"The Model Snapshot Trap\"\u003eThe Model Snapshot Trap\u003c/a\u003e\u003c/h2\u003e\n\u003cp\u003eEF Core\u0026rsquo;s migration system works by diffing the current model against a stored snapshot. That snapshot lives in \u003ccode\u003eCmsDbContextModelSnapshot.cs\u003c/code\u003e next to your migration files, and EF Core generates and updates it automatically every time you run \u003ccode\u003edotnet ef migrations add\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eOnce a plugin entity appears in a migration, it enters the snapshot. Removing the \u003ccode\u003eDbSet\u0026lt;Post\u0026gt;\u003c/code\u003e property from \u003ccode\u003eCmsDbContext\u003c/code\u003e does not clean the snapshot. When you run the next migration (even one completely unrelated to posts), EF Core diffs the new model (no \u003ccode\u003ePost\u003c/code\u003e) against the snapshot (has \u003ccode\u003ePost\u003c/code\u003e) and generates a \u003ccode\u003eDROP TABLE [blog].[Posts]\u003c/code\u003e statement. If that migration reaches production, you have deleted the plugin\u0026rsquo;s data.\u003c/p\u003e\n\u003cp\u003eThe snapshot trap makes contamination sticky. You cannot simply stop referencing the plugin entity and move on. You have to either:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eKeep the entity in the model and accept permanent coupling to the plugin\u0026rsquo;s schema\u003c/li\u003e\n\u003cli\u003eSurgically remove the entity from both the migration file and \u003ccode\u003eCmsDbContextModelSnapshot.cs\u003c/code\u003e, a manual edit that is easy to get wrong and leaves the history in an inconsistent state\u003c/li\u003e\n\u003cli\u003eUse \u003ccode\u003eExcludeFromMigrations()\u003c/code\u003e before the entity ever enters the snapshot, which prevents DDL ownership from being recorded in the first place\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThe 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.\u003c/p\u003e\n\n\n\n\n\u003ch2 id=\"why-the-obvious-fixes-fall-short\"\u003e\u003ca href=\"/posts/efcore-excludefrommigrations-1-the-problem/#why-the-obvious-fixes-fall-short\" title=\"Why the Obvious Fixes Fall Short\"\u003eWhy the Obvious Fixes Fall Short\u003c/a\u003e\u003c/h2\u003e\n\u003cp\u003eThe instinctive response is to reach for \u003ccode\u003emodelBuilder.Ignore\u0026lt;Post\u0026gt;()\u003c/code\u003e or the \u003ccode\u003e[NotMapped]\u003c/code\u003e attribute. Both work, in the sense that EF Core stops generating migrations for the entity. But they also strip EF Core\u0026rsquo;s awareness of the type entirely. You can no longer query \u003ccode\u003ePosts\u003c/code\u003e through the \u003ccode\u003eCmsDbContext\u003c/code\u003e. If your dashboard needs to join pages with posts, you have lost EF Core\u0026rsquo;s ability to help you do that.\u003c/p\u003e\n\u003cp\u003eTake the dashboard scenario from earlier as a concrete case. A \u0026ldquo;recent activity\u0026rdquo; widget needs pages joined with their latest posts, filtered by author and ordered by publish date. With \u003ccode\u003ePost\u003c/code\u003e mapped, that is one LINQ query. Call \u003ccode\u003eIgnore\u0026lt;Post\u0026gt;()\u003c/code\u003e and that query is gone: you are left writing raw SQL against \u003ccode\u003eblog.Posts\u003c/code\u003e, or standing up a second \u003ccode\u003eDbContext\u003c/code\u003e and stitching results together in memory. Both cost real maintenance effort for a problem unrelated to the dashboard feature.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eHasNoKey()\u003c/code\u003e combined with \u003ccode\u003e.ToView(\u0026quot;Posts\u0026quot;, \u0026quot;blog\u0026quot;)\u003c/code\u003e is closer: the entity becomes read-only and EF Core skips migration generation for views. But it changes the entity\u0026rsquo;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.\u003c/p\u003e\n\u003cp\u003eWhat you actually want is a way to tell EF Core: \u0026ldquo;know about this entity, keep it in the model, let me query it, but do not touch its table in migrations.\u0026rdquo; 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.\u003c/p\u003e\n","date_modified":"2026-07-07T23:06:03+02:00","date_published":"2026-07-07T23:00:00+02:00","id":"https://daily-devops.net/posts/efcore-excludefrommigrations-1-the-problem/","language":"en","summary":"When a host DbContext discovers plugin entities, it hijacks their migrations. Here is why EF Core entity discovery causes this and what breaks.","tags":["efcore","dotnet","csharp","architecture","plugins"],"title":"EF Core Plugins: When Migrations Go Wrong","url":"https://daily-devops.net/posts/efcore-excludefrommigrations-1-the-problem/"}],"language":"en","title":"EF Core: Migrations and Data Access Patterns on Daily DevOps \u0026 .NET","version":"https://jsonfeed.org/version/1.1"}