Skip to content

Database Migrations

Schema changes are managed with EF Core migrations and applied by the dedicated Cargonerds.DbMigrator console host, which also runs the ABP data seeders. The migrator is a deployable artifact in its own right — under Aspire it is an application resource, and in the Azure App Service pipeline it is its own service slot — so applying migrations is an explicit, gated step, never a side-effect of an app host starting up.

This page covers what gets migrated, the DbMigrator host, how to add a migration, how seeding works, and how the migrator runs under Aspire and CI/CD.

What gets migrated

The solution has three DbContexts (see Entity Framework Core), but only the host database has EF Core migrations:

DbContext Connection string Migrations? Migrated by DbMigrator?
CargonerdsDbContext Default (the "Spark" DB) Yes — src/Cargonerds.EntityFrameworkCore/Migrations/ Yes
HubDbContext Hub No Migrations folder No
PricingDbContext Pricing No Migrations folder No

The migrations live in src/Cargonerds.EntityFrameworkCore/Migrations/ and target CargonerdsDbContext. Alongside the timestamped migration pairs sits the model snapshot CargonerdsDbContextModelSnapshot.cs, which EF Core diffs against the current model when you scaffold a new migration.

The Hub and Pricing databases are not migrated here

HubDbContext (the Hub database) and PricingDbContext (the Pricing database) have no EF Core Migrations folder and no schema migrator registered. Their schema is managed outside this pipeline (an external DB project / hand-written scripts). The EF model for the Hub deliberately mirrors a hand-managed schema — ShipmentTypeConfiguration even declares an existing SQL trigger and IsCreatedOnline() indexes. builder.ConfigureHub() is present but commented out in CargonerdsDbContext.OnModelCreating, so Hub entities are not mapped into the migrated context. If you change a Hub entity, there is no dotnet ef migrations add to run — coordinate the schema change through the external Hub database.

The schema migrator the pipeline actually executes is EntityFrameworkCoreCargonerdsDbSchemaMigrator (src/Cargonerds.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreCargonerdsDbSchemaMigrator.cs), which implements ICargonerdsDbSchemaMigrator and resolves the context from the service provider so the current scope's connection string is used:

public async Task MigrateAsync()
{
    /* We intentionally resolving the CargonerdsDbContext
     * from IServiceProvider (instead of directly injecting it)
     * to properly get the connection string of the current tenant in the
     * current scope. */
    await _serviceProvider.GetRequiredService<CargonerdsDbContext>().Database.MigrateAsync();
}

A NullCargonerdsDbSchemaMigrator exists in Cargonerds.Domain as the ABP-template fallback (used when no EF provider registers a real migrator); in this solution the EF Core implementation is always present, so the migrator only ever migrates the Default DB.

The DbMigrator project

src/Cargonerds.DbMigrator is a small console host. Its pieces:

File Role
Program.cs Entry point; parses CLI flags, builds the host (plain or web), maps the status endpoint.
CargonerdsDbMigratorModule.cs The ABP module ([DependsOn] EF Core, Redis cache, Hangfire, app contracts).
DbMigratorHostedService.cs IHostedService that boots the ABP application and runs the migration service.
MigrationStateManger.cs / MigrationState.cs Tracks NotStarted → InProgress → Completed/Failed for the status endpoint.
CargonerdsDbMigrationService.cs The actual orchestration (lives in src/Cargonerds.Domain/Data/).

Startup flow

DbMigratorHostedService.StartAsync creates the ABP application with AbpApplicationFactory.CreateAsync<CargonerdsDbMigratorModule>(...), using Autofac, Serilog, the host configuration, and AddDataMigrationEnvironment() (the ABP marker that puts the app in data-migration mode). It then resolves CargonerdsDbMigrationService and either validates or migrates depending on the VALIDATE_MIGRATIONS_ONLY flag:

var migrationService = application.ServiceProvider.GetRequiredService<CargonerdsDbMigrationService>();
var validateOnly = _configuration.GetValue("VALIDATE_MIGRATIONS_ONLY", false);

if (validateOnly)
{
    await migrationService.ValidateNoPendingMigrationsAsync();
}
else
{
    await migrationService.MigrateAsync();
}

CargonerdsDbMigrationService.MigrateAsync() runs these steps in order:

  1. AddInitialMigrationIfNotExist() — if the EF Core project has no Migrations folder at all, it shells out to the ABP CLI (abp create-migration-and-run-migrator) to scaffold the Initial migration and returns early. In a normal repo (where migrations already exist) this is a no-op.
  2. ValidateNoPendingModelChangesAsync() — calls Database.HasPendingModelChanges() on every registered ICargonerdsDbSchemaMigrator; throws Pending model changes found... if the model has drifted from the migrations. This is what catches an entity/config change that forgot a migration.
  3. MigrateDatabaseSchemaAsync() — calls Database.MigrateAsync() (applies pending migrations).
  4. SeedDataAsync() — runs the ABP data seeders (see Seeding below).
flowchart TD
    A[DbMigrator host starts] --> B[Create ABP application<br/>AddDataMigrationEnvironment]
    B --> C{VALIDATE_MIGRATIONS_ONLY?}
    C -- "true (CI / validate)" --> V1[ValidateNoPendingModelChanges]
    V1 --> V2[Validate no pending migrations]
    V2 --> S[MigrationState = Completed/Failed]
    C -- "false (apply)" --> I[AddInitialMigrationIfNotExist]
    I -->|no Migrations folder| Z[Run ABP CLI, return early]
    I -->|folder exists| D[ValidateNoPendingModelChanges]
    D --> M[MigrateDatabaseSchema<br/>Database.MigrateAsync]
    M --> SD[SeedData]
    SD --> S
    S --> R[Report via /migration/status<br/>or exit process]

Multi-tenancy is disabled

MigrateAsync() and ValidateNoPendingMigrationsAsync() contain a per-tenant loop, but MultiTenancyConsts.IsEnabled is false in this solution, so that loop is dead code in practice. A separate CargonerdsTenantDatabaseMigrationHandler (a distributed event handler for TenantCreatedEto / ApplyDatabaseMigrationsEto) would migrate-and-seed a tenant DB at runtime, but it is likewise inert while multi-tenancy is off. The host database is the only thing migrated.

Validation-only mode

Setting VALIDATE_MIGRATIONS_ONLY=true switches the host to ValidateNoPendingMigrationsAsync(), which checks but never applies. It:

  • fails fast if AddInitialMigrationIfNotExist() had to create an initial migration ("Initial migration was generated. Pending migrations must be applied."),
  • runs ValidateNoPendingModelChangesAsync() (model vs. migrations), and
  • runs ValidatePendingMigrationsForCurrentTenantAsync() (Database.GetPendingMigrationsAsync()), throwing with the list of un-applied migration names if any exist.

This is used to fail a build/pipeline that forgot a migration, or to short-circuit startup when the DB is already known to be up to date. Under Aspire the AppHost forwards this flag from the run-mode configuration (see Running under Aspire).

CLI flags

Program.Main reads two switches from args:

Flag Effect
--enable-api Builds a slim web host (WebApplication.CreateSlimBuilder) and maps GET /migration/status so an external pipeline can poll progress. Without it, a plain Host.CreateDefaultBuilder console host is built and the process exits when migration completes.
--disable-redis Sets Redis:IsEnabled=false in configuration (in PreConfigureServices) so the migrator does not require Redis.

Migration status endpoint

With --enable-api, the host stays alive and exposes:

GET /migration/status?token=<PIPELINE_AUTH_TOKEN>

The handler compares the token query value against the PIPELINE_AUTH_TOKEN configuration value (returns 503 if no token is configured, 401 on mismatch) and otherwise returns the current state:

{ "state": "InProgress", "exception": null }

state is one of NotStarted, InProgress, Completed, Failed (the MigrationState enum); exception is an unfolded message + inner-exception chain + stack trace when a failure occurred.

Why the process behaves differently with the API on

MigrationStateManger changes behaviour based on --enable-api. Console mode: a failure rethrows (non-zero exit) and success calls hostApplicationLifetime.StopApplication() so the process exits. API mode: a failure is captured into State = Failed / Exception so the poller can read it, and the host is kept running ("the AppService will be shutdown directly during deployment" — the pipeline stops the slot once it sees Completed).

Module dependencies

CargonerdsDbMigratorModule pulls in only what migration + seeding need:

[DependsOn(
    typeof(AbpAutofacModule),
    typeof(AbpCachingStackExchangeRedisModule),
    typeof(CargonerdsEntityFrameworkCoreModule),
    typeof(CargonerdsApplicationContractsModule),
    typeof(AbpBackgroundJobsHangfireModule)
)]
public class CargonerdsDbMigratorModule : AbpModule

It also configures the distributed-cache key prefix (Cargonerds: plus the Azure environment name) and registers Hangfire SQL Server storage against the Default connection string. Hangfire is present because the seeders pull in application/domain services that expect background-job infrastructure to be wired up.

Configuration & layering

The migrator reads its connection string and OpenIddict seed data from configuration, layered in this order (Program.CreateHostBuilder):

  1. appsettings.json — checked-in defaults. Only the Default connection string is present (LocalDB), plus the OpenIddict client/scope seed definitions.
  2. appsettings.secrets.json — local secrets overlay (AddAppSettingsSecretsJson).
  3. appsettings.aspire.json — added only when USE_ASPIRE_CONFIG=true; rewires Redis/RabbitMQ and the OpenIddict client endpoints to Aspire service tokens ({redis}, {messaging}, {api}, {admin}, …).
  4. appsettings.azure.migrator.<env>.json — added when an Azure environment is resolved (appsettings.azure.migrator.dev.json, ...prod.json, ...prod.staging.json, plus the dev.hub-test / dev.hub-prod variants).
  5. A ServiceDiscoveryConfigurationSource for resolving service-token placeholders.

Where the design-time tools read the connection string

EF Core's design-time commands do not use the migrator's runtime configuration. The IDesignTimeDbContextFactory, CargonerdsDbContextFactory, reads ConnectionStrings:Default from ../Cargonerds.DbMigrator/appsettings.json relative to the EF Core project directory:

new ConfigurationBuilder()
    .SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "../Cargonerds.DbMigrator/"))
    .AddJsonFile("appsettings.json", optional: false);

So dotnet ef migrations add scaffolds against whatever Default points to in the DbMigrator's checked-in appsettings.json (LocalDB by default). It also calls CargonerdsEfCoreEntityExtensionMappings.Configure() first so ABP object-extension columns are part of the scaffolded model.

Adding a migration

Because migrations target CargonerdsDbContext and the design-time factory reads from the DbMigrator project, run the EF Core tooling against the EF Core project:

dotnet ef migrations add <Name> \
  --project src/Cargonerds.EntityFrameworkCore \
  --startup-project src/Cargonerds.EntityFrameworkCore

Or use the ABP CLI helper, which scaffolds the migration and runs the DbMigrator in one step:

abp create-migration-and-run-migrator src/Cargonerds.EntityFrameworkCore

Then apply it by running the migrator directly (or let Aspire / CI run it):

dotnet run --project src/Cargonerds.DbMigrator

Keep the model and migrations in sync

Both MigrateAsync() and ValidateNoPendingMigrationsAsync() call ValidateNoPendingModelChangesAsync() first, which delegates to Database.HasPendingModelChanges(). A model change (new entity, changed IEntityTypeConfiguration, a new ABP object-extension property) without a matching migration throws Pending model changes found for host database: ... and the migrator aborts before touching the DB. Always scaffold a migration for entity/configuration changes. CI enforces this via VALIDATE_MIGRATIONS_ONLY (see Running in CI/CD).

Migrations are not only schema diffs

Migrations in this project also carry raw SQL for things EF cannot express, so review the generated Up/Down and add SQL where needed:

  • Computed columns20260618140521_AddUserCountryIsoComputedColumn adds a stored computed column on AbpUsers for country filtering and indexes it:
migrationBuilder.AddColumn<string>(
    name: "CountryIso",
    table: "AbpUsers",
    computedColumnSql: "LTRIM(RTRIM(JSON_VALUE(ExtraProperties, '$.country')))",
    stored: true);
migrationBuilder.CreateIndex("IX_AbpUsers_CountryIso", "AbpUsers", "CountryIso");
  • Data-only (backfill) migrations20260612090000_BackfillExcelExportFilterContext has no Designer file (it makes no model change); its Up is a hand-written UPDATE ... JSON_MODIFY and its Down is intentionally empty. This is the pattern to follow for data fixes that ride along the migration history.

Seeding

After the schema is applied, SeedDataAsync() invokes ABP's IDataSeeder, which runs every registered IDataSeedContributor. The seed context is given the default admin credentials and a flag to seed in a separate unit of work:

await _dataSeeder.SeedAsync(
    new DataSeedContext(tenant?.Id)
        .WithProperty(IdentityDataSeedContributor.AdminEmailPropertyName,
            CargonerdsConsts.AdminEmailDefaultValue)
        .WithProperty(IdentityDataSeedContributor.AdminPasswordPropertyName,
            CargonerdsConsts.AdminPasswordDefaultValue)
        .WithProperty(DataSeederExtensions.SeedInSeparateUow, true));

The project's own contributors (in src/Cargonerds.Domain/) are:

Contributor Seeds
DefaultUsersDataSeedContributor The admin role with the InternalAdmin permission, a fixed set of named Cargonerds users (added to the admin role), and the Default Organization Unit. In Prod users are created without a password (forcing a reset); elsewhere with the default password. Disables the soft-delete filter so previously deleted users are re-enabled rather than duplicated.
RolesDataSeedContributor The RoleConsts.Hierarchy roles, marks DefaultCustomer as the default role, and grants permissions discovered via the [GrantInRoles] attribute across HubPermissions, CargonerdsPermissions, PricingPermissions (with role inheritance through the hierarchy).
OpenIddictDataSeedContributor OpenIddict applications (clients) and scopes from the OpenIddict configuration section (see Authentication).
SaasDataSeedContributor SaaS/tenant seed data (inert while multi-tenancy is disabled).

These run in addition to the framework contributors that ship with the ABP modules (Identity, permission/feature management, language management, etc.). All contributors are idempotent — they check for existing rows before inserting — so running the migrator repeatedly is safe.

Running under Aspire

The AppHost (src/Cargonerds.AppHost/Program.cs) models the migrator as a project resource and wires it ahead of the auth/api hosts:

var migrator = builder.AddProjectWithDefaults<Cargonerds_DbMigrator>(CargonerdsConsts.Aspire.Service.Migrator);

migrator
    .WaitFor(db)
    .WithReference(db, "Default")
    .If(
        sparkDbRunModeConfiguration?.ValidateMigrationsOnly ?? false,
        _ => migrator.WithEnvironment(EnvVars.ValidateMigrationsOnlyName, "true")
    );

AddProjectWithDefaults sets USE_ASPIRE_CONFIG=true, which is what makes the migrator load appsettings.aspire.json. The migrator waits for the database to be ready and receives the Default connection string from the Aspire SQL resource. The auth server and API host in turn use .WaitForCompletionIf(!skipWaitingInBackend, migrator), so they do not start serving until the migrator process has exited successfully — giving Aspire the same "migrate, then start" ordering the production pipeline enforces. VALIDATE_MIGRATIONS_ONLY is forwarded only when the local run-mode configuration requests it. See .NET Aspire Integration and Azure Container Apps deployment.

Running in CI/CD

Pull-request validation

VALIDATE_MIGRATIONS_ONLY is the gate that keeps the model and migrations in sync in CI — the migrator runs in validate-only mode and fails the build if the model has pending changes or there are un-applied migrations, without writing to any database.

Azure App Service production handshake

In the Azure App Service pipeline (.scripts/MigrateAppServiceDb.ps1, invoked by DeployToAppServices.ps1) the migrator is its own deployment slot and migration is a controlled, token-authenticated handshake — this is where --enable-api and /migration/status earn their keep:

  1. Refuse to proceed unless the migrator slot is currently Stopped (a running migrator means one is already in progress).
  2. Generate a random 50-char PIPELINE_AUTH_TOKEN and set it as an app setting on the slot.
  3. Deploy DbMigrator.zip (with retry + Kudu SCM-log verification for the flaky az webapp deploy), then az webapp start the slot.
  4. Poll https://<migrator>/migration/status?token=<token> until state == Completed (Failed aborts the deployment).
  5. In finally, stop the slot and delete the PIPELINE_AUTH_TOKEN app setting — the token is short-lived and never persists past the run.

The slot-swap path (SwapAppServiceSlots.ps1) runs a fresh migration against the production-slot database before warming up and swapping, so production is always migrated under the production connection string. For the full deployment picture see CI/CD pipelines and Azure App Service deployment.

DbMigrator runs as a service, not at app startup

The application hosts (AuthServer, HttpApi.Host, Blazor) do not apply migrations on startup. Migrations only run when the Cargonerds.DbMigrator host is executed — locally, via the Aspire db-migrator resource, or via the App Service migrator slot. If a schema looks stale, check that the migrator actually ran and completed.

Gotchas

  • Only the Default DB is EF-migrated. The Hub and Pricing databases have no migrations and are not touched by this pipeline. Changing a Hub/Pricing entity requires an out-of-band schema change.
  • Pending model changes abort the run. HasPendingModelChanges() is checked before applying. Forgetting a migration (including for ABP object-extension properties) throws before any DB write.
  • Design-time vs. runtime connection strings differ. dotnet ef scaffolds against Default in Cargonerds.DbMigrator/appsettings.json (LocalDB by default), while the running migrator uses the layered Aspire/Azure configuration. Point your local Default at the right DB before scaffolding.
  • Status states are NotStarted / InProgress / Completed / Failed. The JSON field is state (the App Service script also reads exception); don't expect a Started state.
  • --enable-api changes failure behaviour. Without it, a failure throws and the process exits non-zero; with it, the failure is swallowed into the status endpoint and the host stays up for the pipeline to read and then stop.
  • The per-tenant loop is dead code. Multi-tenancy is disabled; the tenant migration loop and CargonerdsTenantDatabaseMigrationHandler never run in the current configuration.
  • Data-only migrations omit the Designer file and need a hand-written Down (or an explicit comment that there is nothing to revert).