DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on

When EF Core's Design-Time Model Is Supposed to Be Incomplete

Framework warnings are useful because they compress hard-earned experience into an early signal. But a signal is not an invariant. In an extensible EF Core architecture, design-time tooling can legitimately see less of the model than the running application. If that difference is intentional, treating every pending-model warning as real drift can block a safe database update—or encourage a destructive migration.

The answer is not to ignore the warning casually. It is to understand exactly why it is false, then replace the lost signal with checks that understand the real architecture.

How a Valid Model Becomes Invisible

Consider a modular .NET application with a DbContext owned by a foundational module. Higher-level modules can contribute entity configurations through runtime discovery. This keeps the dependency direction clean: the foundational data module does not need references back to every feature that extends it.

At runtime, the host loads the feature assemblies and the DbContext receives the complete set of configurations. At design time, the EF Core tooling starts from the foundational project. Loading a higher-level contributor from there would create a circular dependency, so the design-time process builds a smaller model.

The migration snapshot still represents the complete model. The design-time differ compares that snapshot with its partial view and concludes that the contributed entity has disappeared. A probe migration may therefore contain a drop operation for a table that remains valid and necessary at runtime.

The tool is behaving consistently with the inputs it can see. The architectural context is what it lacks.

Prove the False Positive Before Suppressing It

Before changing warning behaviour, reduce the difference to something explainable.

Generate a disposable probe migration and inspect every operation. If the only reported difference is the known extension entity that design time cannot load, you have evidence for a visibility gap. If the probe also changes columns, indexes, relationships, or unrelated tables, stop: that may be genuine drift.

Then verify the full model independently. Run a snapshot comparison in a test process where all contributing assemblies are loaded. That test should compare the migration snapshot against the same model the application actually builds.

Only after both checks agree should the design-time factory suppress the specific pending-model warning. The suppression belongs at the narrow boundary where the false positive occurs, not in a broad configuration that hides the warning for unrelated contexts.

This is an important distinction: the warning is disabled because a stronger source of truth exists, not because the warning is inconvenient.

Turn the Destructive Failure Mode Into a Build Failure

Even after database update is unblocked, migration scaffolding may still use the incomplete design-time model. A future developer can generate a migration and receive a plausible-looking drop operation.

Comments and runbooks help, but they do not stop a merge. An executable guard can.

Inspect every migration's operations and fail if a protected extension table is dropped, renamed, or destructively altered. Cover structured operations such as table drops and column drops, but also inspect raw SQL for equivalent destructive statements. The goal is not a general SQL parser. It is a focused tripwire for the known architectural blind spot.

Add a positive control as well. The suite should prove that migration discovery found the migration which creates the protected table. Without that check, a broken scanner could inspect zero migrations and report a reassuring green result. A rejection-only test cannot distinguish “nothing is wrong” from “nothing ran.”

That small positive assertion dramatically improves the credibility of the safety test.

Keep the Test Harness Honest

Design-time factory tests often need temporary configuration, such as a process-wide environment variable containing a connection string. Two test classes that mutate the same variable can race: one restores the original value while the other is still constructing its context.

That creates a flaky failure which disappears when either class runs alone.

Treat process-wide configuration as shared mutable state. Put those tests in one serialized collection, restore the previous value in a finally block, and make it explicit that the connection value is only used to construct options when no database call is required.

This detail is adjacent to the model problem, but it matters. A safety test that flakes trains people to distrust or bypass it.

The Trade-Off Is Custom Safety Work

This approach is not free. The team must maintain a full-model snapshot test, a focused migration-operation guard, a positive control, serialized environment-sensitive tests, and documentation explaining which CLI signal is intentionally unreliable for this context.

The alternative is worse: reverse the dependency direction, abandon modular extension points, block legitimate database updates, or risk committing a destructive migration produced by a partial model.

The general rule is simple:

  1. Identify why the generic check is wrong.
  2. Prove the exact boundary of the false positive.
  3. Suppress only at that boundary.
  4. Replace the lost signal with a stronger invariant.
  5. Add a positive control so the invariant cannot pass vacuously.

Framework defaults are valuable, but architecture-specific evidence is stronger. When design time and runtime intentionally see different worlds, make that difference explicit—and make the dangerous interpretation impossible to merge unnoticed.

Top comments (0)