DEV Community

Cover image for Never Let Migration Tooling Guess the Database
Ivan Rossouw
Ivan Rossouw

Posted on

Never Let Migration Tooling Guess the Database

One of the more dangerous database configuration errors is not a failed connection. It is a successful connection to a database nobody deliberately selected.

That is why an EF Core design-time DbContext factory deserves more scrutiny than “CLI plumbing”. It decides how tooling constructs the model outside the application’s normal startup path. If it also searches broadly for connection details, it can grant a migration command ambient authority.

A recent committed cleanup made this concrete for me. One design-time factory still fell back to configuration owned by an application host that no longer owned the module. The runtime architecture had been separated, but the tooling still crossed the old boundary.

The change removed that fallback. The lesson is broader: design-time configuration is a database-safety boundary.

A fallback is an authority decision

Application startup often combines JSON files, environment variables, secret stores, and deployment configuration. That flexibility is useful because the running application has a defined environment and composition root.

The EF Core CLI operates in a different context. It may run from a module directory, a developer workstation, or CI. A design-time factory that walks the repository or borrows another host’s settings can discover a connection string the operator never consciously selected.

The unintended target does not have to be production to cause harm. It might be a shared development database, an integration environment, or a database belonging to another application boundary. The problem is the same: success looks legitimate even though intent was never established.

Convenient fallback logic answers a security-relevant question: “If no target was chosen, which target should the tool receive?” The safest answer is none.

Model construction is not connection authority

Throwing immediately when configuration is absent is safe, but it can be unnecessarily blunt. Several design-time operations need the model and migration assembly, not a live database. Developers should be able to inspect or scaffold migrations without carrying database credentials.

This gives us two distinct capabilities:

  1. Construct the model for offline design-time work.
  2. Open a database connection and potentially change state.

They should not share an implicit permission boundary.

A useful pattern is to let the provider options use an unmistakably unreachable sentinel when no connection is configured. Model-only commands can still create the context. Any operation that opens a connection fails with a recognisable error instead of guessing a real target.

A generic factory shape

The following example is intentionally generic. The sentinel value is provider-specific and belongs in a small, clearly named helper; it should never resemble a real server.

public sealed class DesignTimeFactory
    : IDesignTimeDbContextFactory<AppDbContext>
{
    public AppDbContext CreateDbContext(string[] args)
    {
        var explicitRoot = Environment.GetEnvironmentVariable(
            "MIGRATIONS_CONFIG_PATH");

        var root = string.IsNullOrWhiteSpace(explicitRoot)
            ? Directory.GetCurrentDirectory()
            : explicitRoot;

        var configuration = new ConfigurationBuilder()
            .SetBasePath(root)
            .AddJsonFile(
                "appsettings.DesignTime.json",
                optional: true)
            .AddEnvironmentVariables(prefix: "MIGRATIONS_")
            .Build();

        var configuredConnection = configuration
            .GetConnectionString("Database");

        var connection = string.IsNullOrWhiteSpace(
            configuredConnection)
            ? UnreachableDesignTimeConnection.Value
            : configuredConnection;

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(connection)
            .Options;

        return new AppDbContext(options);
    }
}
Enter fullscreen mode Exit fullscreen mode

The useful properties are more important than the exact APIs:

  • An explicit root controls where design-time files are read.
  • The current directory is a local, visible fallback.
  • Environment variables are added last, so matching values override files.
  • No unrelated application host is searched.
  • Missing connection authority produces an impossible target, not a plausible default.

Avoid using localhost, a familiar shared server, or an empty value as the sentinel. localhost may contain real data. A familiar name defeats the boundary. An empty value may prevent the provider from constructing options, which blocks the offline work we are trying to preserve.

The sentinel is only a defensive default. It does not replace least-privilege credentials, network controls, deployment review, backups, or a safe migration process.

Test both sides of the boundary

Tests should describe the capability split, not merely prove that the factory returns a context.

Useful checks include:

  • With no configuration, a model-only command can construct the context.
  • With no configuration, a connecting command fails against the recognisable sentinel.
  • With explicit configuration, a connecting command reaches only an isolated disposable database.
  • Configuration precedence is deterministic and documented.
  • Narrowing the configuration path does not change the model or hide migration history.

The last check matters because a safety refactor should not accidentally produce schema drift. In the committed change that prompted this lesson, the recorded verification covered the existing test suite, migration discovery, and a model-drift check. This scheduled review did not rerun repository commands, so I treat those as committed evidence rather than fresh execution results.

Make invisible trust visible

There is a real trade-off. Explicit configuration adds ceremony. A missing setting may not fail until a command actually tries to connect. Developers need a short guide explaining which commands work offline and how to opt into database access.

That cost is modest compared with ambiguous success.

Design-time tools sit outside the normal runtime composition root, but they can still hold production-capable privileges. Review their fallbacks as authorisation decisions. Removing one ambient source does not validate every remaining source; it removes one unintended-target path. Keep model work easy, narrow the permitted sources, and make missing configuration conspicuous.

Where does your migration workflow still select authority on the operator’s behalf?

Top comments (0)