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-efinstalled, SQL Server running
One DbContext and schema per module
Section titled “One DbContext and schema per module”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:
.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.
Add a migration
Section titled “Add a migration”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.
-
Install the EF Core tools. The repo has no local tool manifest, so install
dotnet-efglobally. Version 10.0.14 matches the EF Core packages inDirectory.Packages.props.Terminal window dotnet tool install --global dotnet-ef --version 10.0.14An 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'. -
Change the model. Edit the entity and its
IEntityTypeConfigurationin the module. Each context callsApplyConfigurationsFromAssemblyon its module’s assembly, so a new configuration class needs no registration. A new entity also needs aDbSetand, if it’s tenant-scoped, a named query filter. -
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 CrmDbContextFor another module, swap
--projectand--contextusing the table above. There’s noIDesignTimeDbContextFactory, so the tools buildAppointMe.Apiand take the context from its host in theDevelopmentenvironment, with the connection string fromappsettings.Development.json.migrations adddoesn’t connect to the database. -
Review the files. EF writes next to the existing snapshot, in
Database/Migrations/:File What it is <timestamp>_AddCustomerNotes.csUp()andDown(). Read this one.<timestamp>_AddCustomerNotes.Designer.csThe model as of this migration. Generated, don’t edit. CrmDbContextModelSnapshot.csThe current model, updated in place The snapshots at
2ccbe87carryProductVersion10.0.9(Identity, Organizations) or10.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. -
Apply it. Start the API, through Aspire or
dotnet run --project src/AppointMe.Api. Startup applies every pending migration and logsApplying migration '<timestamp>_AddCustomerNotes'.To apply without starting the app, rundotnet ef database updatewith the same--project,--startup-projectand--contextoptions.
How migrations are applied at startup
Section titled “How migrations are applied at startup”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.
hangfire schema and registers recurring jobsMigrateAsync per module, in registration orderwolverine_* tablesEach 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.
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:
if (isCodegen){ // Build-time OpenAPI generation starts the host; it must not reach for the database. builder.Services.DisableDatabaseMigrations();}Tables EF doesn’t manage
Section titled “Tables EF doesn’t manage”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 |
.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 or reset
Section titled “Roll back or reset”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.
In production
Section titled “In production”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.
Troubleshooting
Section titled “Troubleshooting”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.