Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 20269 min read

Database migrations

How EF Core migrations work in AppointMe's .NET 10 modular monolith, with one DbContext and schema per module, the dotnet ef command for each, and a hosted service that applies them at startup.

You'll do
Add, apply and roll back a module migration
Time
About 5 minutes per migration
Touches
<Module>/Database/Migrations/ and its snapshot
Before you start
dotnet-ef installed, SQL Server running

Every module owns a DbContext, a SQL schema, a migrations folder and its own __EFMigrationsHistory table, all in the one AppointMe database.

Module --project --context Schema Migrations at 2ccbe87
Identity src/Identity/AppointMe.Identity IdentityDbContext identity 2
Organizations src/Organizations/AppointMe.Organizations OrganizationsDbContext organizations 4
CRM src/CRM/AppointMe.Crm CrmDbContext crm 3
Booking src/Booking/AppointMe.Booking BookingDbContext booking 4

Migrations live in Database/Migrations/ inside each module project. The context sets its default schema in OnModelCreating (for example modelBuilder.HasDefaultSchema(DefaultSchema)), and the module puts the history table in that schema when it registers the context:

src/CRM/AppointMe.Crm/Configuration/CrmModule.csView source ↗
.AddDbContext<CrmDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(serviceProvider.GetRequiredService<ConnectionStrings>().AppointMeSql,
builder =>
{
builder.MigrationsHistoryTable("__EFMigrationsHistory", CrmDbContext.DefaultSchema);
});
})
.AddDatabaseMigration<CrmDbContext>()

The last line registers the context with the startup migration runner. For how modules stay separate, see Modular monolith.

Run dotnet ef from the repo root with the module as --project and the API as --startup-project. The tools boot the API’s host to build the context.

  1. Install the EF Core tools. The repo has no local tool manifest, so install dotnet-ef globally. Version 10.0.14 matches the EF Core packages in Directory.Packages.props.

    Terminal window
    dotnet tool install --global dotnet-ef --version 10.0.14

    An older 10.x tool still works but prints The Entity Framework tools version '10.0.3' is older than that of the runtime '10.0.14'.

  2. Change the model. Edit the entity and its IEntityTypeConfiguration in the module. Each context calls ApplyConfigurationsFromAssembly on its module’s assembly, so a new configuration class needs no registration. A new entity also needs a DbSet and, if it’s tenant-scoped, a named query filter.

  3. Generate the migration. From the repo root:

    Terminal window
    dotnet ef migrations add AddCustomerNotes --project src/CRM/AppointMe.Crm --startup-project src/AppointMe.Api --context CrmDbContext

    For another module, swap --project and --context using the table above. There’s no IDesignTimeDbContextFactory, so the tools build AppointMe.Api and take the context from its host in the Development environment, with the connection string from appsettings.Development.json. migrations add doesn’t connect to the database.

  4. Review the files. EF writes next to the existing snapshot, in Database/Migrations/:

    File What it is
    <timestamp>_AddCustomerNotes.cs Up() and Down(). Read this one.
    <timestamp>_AddCustomerNotes.Designer.cs The model as of this migration. Generated, don’t edit.
    CrmDbContextModelSnapshot.cs The current model, updated in place

    The snapshots at 2ccbe87 carry ProductVersion 10.0.9 (Identity, Organizations) or 10.0.10 (CRM, Booking). The first migration you add with the 10.0.14 packages rewrites that annotation too. That’s expected, so commit it with the migration.

  5. Apply it. Start the API, through Aspire or dotnet run --project src/AppointMe.Api. Startup applies every pending migration and logs Applying migration '<timestamp>_AddCustomerNotes'. To apply without starting the app, run dotnet ef database update with the same --project, --startup-project and --context options.

A hosted service in the shared kernel migrates every registered context, one module at a time, before Wolverine starts and before the API serves a request.

Hangfirecreates the hangfire schema and registers recurring jobs
DatabaseMigrationServiceMigrateAsync per module, in registration order
Wolverinecreates or updates its wolverine_* tables
Kestrelstarts serving requests
Figure 1. Startup order on a local run, as the API logs it.

Each module’s AddDatabaseMigration<TContext>() adds its context to a list and registers DatabaseMigrationService once. The service walks the list in the order Program.cs adds the modules: Identity, Organizations, CRM, then Booking.

src/AppointMe.Shared/Database/Migrations/DatabaseMigrations.csView source ↗
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
foreach (var contextType in options.Value.ContextTypes)
{
await MigrateContext(scope.ServiceProvider, contextType, cancellationToken);
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task MigrateContext(IServiceProvider serviceProvider, Type contextType,
CancellationToken cancellationToken)
{
var context = (DbContext)serviceProvider.GetRequiredService(contextType);
logger.LogInformation("Applying migrations for {Context}...", contextType.Name);
await context.Database.MigrateAsync(cancellationToken);
logger.LogInformation("Migrations applied for {Context}.", contextType.Name);
}

MigrateAsync takes EF Core’s exclusive migration lock first (the log says Acquiring an exclusive lock for migration application.), so two instances starting together don’t race. A failure stops startup. Build-time runs skip the service, because generating the OpenAPI document boots the host with no database:

src/AppointMe.Api/Program.csView source ↗
if (isCodegen)
{
// Build-time OpenAPI generation starts the host; it must not reach for the database.
builder.Services.DisableDatabaseMigrations();
}

Wolverine and Hangfire share the database but create and upgrade their own tables at startup. Never add EF migrations for them.

Owner Tables Created by Notes
Wolverine 9 wolverine_* tables: incoming and outgoing envelopes, dead letters, node records Wolverine, when it starts, using the AppointMeSql connection string On a fresh database at 2ccbe87 they’re created in dbo, even though the code sets MessageStorageSchemaName = "wolverine"
Hangfire 11 tables in the hangfire schema, such as Job, State and Server Hangfire, because PrepareSchemaIfNecessary = true Not set up in build-time runs
src/AppointMe.Api/Hangfire/HangfireExtensions.csView source ↗
.UseSqlServerStorage(connectionString, new SqlServerStorageOptions
{
SchemaName = "hangfire",
PrepareSchemaIfNecessary = true,
})

Wolverine’s tables are its inbox and outbox. Dropping them while messages are in flight loses those messages, so reset them only with the whole database. See Domain events & messaging and Background jobs.

Roll back one module with dotnet ef. To start from an empty database, delete the SQL Server data volume.

Goal Do this
Undo the last migration, not yet applied dotnet ef migrations remove with the module’s options. It connects to the database to check whether the migration was applied.
Undo an applied migration dotnet ef database update <PreviousMigration> (for example AddCustomerRegistrationDateIndex), then dotnet ef migrations remove
Throw away a never-applied migration with no database running Delete <timestamp>_<Name>.cs and its .Designer.cs, then restore the snapshot with git checkout -- <path to the ModelSnapshot.cs file>
Wipe everything (Aspire) Stop the AppHost. Delete the appointme-sql-<suffix> container and the appointme.aspire-<hash>-appointme-sql-data volume; docker ps -a and docker volume ls show the names.
Wipe everything (Docker Compose) docker compose down -v, which also wipes Keycloak’s volume, then docker compose up -d and create the database (below)

The rollback command runs the migration’s Down(). database update AddCustomerRegistrationDateIndex logged Reverting migration '<timestamp>_AddCustomerNotes'. and dropped the table.

The same startup service migrates the production database each time a container starts, so a deploy is a schema upgrade.

Fact Consequence
Migrations run on container startup, before the API serves traffic A release with a migration starts slower. On the free App Service plan, a cold start includes the migration run.
Every instance runs MigrateAsync EF Core’s migration lock makes the others wait while one applies the migration
The app applies schema changes itself Its SQL identity needs rights to create and alter tables
Down() methods exist Nothing runs them on deploy. Rolling back code doesn’t roll back the schema, so keep migrations additive across a release.

To run migrations as a pipeline step instead, generate a script per module with dotnet ef migrations script --idempotent and the module’s options. It needs no database and wraps each migration in an IF NOT EXISTS check on that module’s history table. Then call DisableDatabaseMigrations() in Program.cs.

AppointMe doesn’t ship this setup. For the deployment itself, see Azure with Bicep.

Most dotnet ef failures are a wrong folder, a stopped SQL Server or an old tool.

Symptom Cause Fix
No project was found. Change the current working directory or use the --project option. The command ran without --project from a folder with no project Run from the repo root with --project and --startup-project
A network-related or instance-specific error occurred while establishing a connection to SQL Server. on remove or database update SQL Server isn’t running at localhost,60740 Start Aspire or docker compose up -d. For a never-applied migration, delete its files instead.
The Entity Framework tools version '10.0.3' is older than that of the runtime '10.0.14'. An older global dotnet-ef dotnet tool update --global dotnet-ef --version 10.0.14
Cannot open database "AppointMe" requested by the login. when the API starts The database doesn’t exist yet Create it (see the Gotcha above), or run through Aspire
A new module’s first migration lands in Migrations/ at the project root There’s no snapshot yet, so EF uses its default folder Pass --output-dir Database/Migrations for the first one. See Add a module.
Why apply migrations at startup instead of in the pipeline?1 min · optional

The deployable is one container, and it always arrives with the schema it needs. There’s no separate migration job to order against the deploy, and a new environment gets its schema the first time the app starts.

The cost is that the app’s identity needs DDL rights and every start pays for the migration check. When either matters, switch to the idempotent script above.

Why a history table per schema instead of one shared table?1 min · optional

Each module’s migrations stay independent. Its history table lists only its own migrations, so a module can be removed together with its schema, history included, and a module moved into its own service takes its history with it.