# 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.

Source page: https://appointme.dev/docs/guides/database-migrations/

* **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

## 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:

src/CRM/AppointMe.Crm/Configuration/CrmModule.cs

```cs
                .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](/docs/concepts/modular-monolith/).

## 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.**

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`.

   ```bash
   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](/docs/concepts/multi-tenancy/#make-your-own-entity-tenant-scoped).

3. **Generate the migration.** From the repo root:

   ```bash
   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.

## 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.**

*Figure 1. Startup order on a local run, as the API logs it.*

1. **Hangfire** — creates the `hangfire` schema and registers recurring jobs
2. **DatabaseMigrationService** — `MigrateAsync` per module, in registration order
3. **Wolverine** — creates or updates its `wolverine_*` tables
4. **Kestrel** — starts serving requests

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.cs

```cs
    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.cs

```cs
if (isCodegen)
{
    // Build-time OpenAPI generation starts the host; it must not reach for the database.
    builder.Services.DisableDatabaseMigrations();
}
```

## 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                                                                                                 |

src/AppointMe.Api/Hangfire/HangfireExtensions.cs

```cs
                .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](/docs/concepts/messaging/) and [Background jobs](/docs/concepts/background-jobs/).

## 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.

Gotcha

The API doesn’t create the database. Aspire does, through `sqlServer.AddDatabase("AppointMeSql", "AppointMe")`. On a SQL Server with no `AppointMe` database, startup stops with `Cannot open database "AppointMe" requested by the login. The login failed.`, because Hangfire’s recurring-job registration runs before the migrations. With Compose or your own server, create the empty database first:

```bash
docker exec appointme-sql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P Password1 -C -Q "CREATE DATABASE AppointMe"
```

## 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](/docs/deploy/azure/).

## 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](/docs/guides/add-a-module/). |

### Why apply migrations at startup instead of in the pipeline?

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?

Each module’s migrations stay independent. Its history table lists only its own migrations, so a module can be [removed](/docs/guides/remove-a-module/) together with its schema, history included, and a module moved into its own service takes its history with it.

## Next

* [Build your first feature](/docs/start/first-feature/) (Tutorial)
* [Multi-tenancy](/docs/concepts/multi-tenancy/) (Concept)
* [Azure with Bicep](/docs/deploy/azure/) (Deploy)
