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:
AddInitialMigrationIfNotExist()— if the EF Core project has noMigrationsfolder 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.ValidateNoPendingModelChangesAsync()— callsDatabase.HasPendingModelChanges()on every registeredICargonerdsDbSchemaMigrator; throwsPending model changes found...if the model has drifted from the migrations. This is what catches an entity/config change that forgot a migration.MigrateDatabaseSchemaAsync()— callsDatabase.MigrateAsync()(applies pending migrations).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:
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 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):
appsettings.json— checked-in defaults. Only theDefaultconnection string is present (LocalDB), plus the OpenIddict client/scope seed definitions.appsettings.secrets.json— local secrets overlay (AddAppSettingsSecretsJson).appsettings.aspire.json— added only whenUSE_ASPIRE_CONFIG=true; rewires Redis/RabbitMQ and the OpenIddict client endpoints to Aspire service tokens ({redis},{messaging},{api},{admin}, …).appsettings.azure.migrator.<env>.json— added when an Azure environment is resolved (appsettings.azure.migrator.dev.json,...prod.json,...prod.staging.json, plus thedev.hub-test/dev.hub-prodvariants).- A
ServiceDiscoveryConfigurationSourcefor 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:
Then apply it by running the migrator directly (or let Aspire / CI run it):
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 columns —
20260618140521_AddUserCountryIsoComputedColumnadds a stored computed column onAbpUsersfor 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) migrations —
20260612090000_BackfillExcelExportFilterContexthas no Designer file (it makes no model change); itsUpis a hand-writtenUPDATE ... JSON_MODIFYand itsDownis 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:
- Refuse to proceed unless the migrator slot is currently
Stopped(a running migrator means one is already in progress). - Generate a random 50-char
PIPELINE_AUTH_TOKENand set it as an app setting on the slot. - Deploy
DbMigrator.zip(with retry + Kudu SCM-log verification for the flakyaz webapp deploy), thenaz webapp startthe slot. - Poll
https://<migrator>/migration/status?token=<token>untilstate == Completed(Failedaborts the deployment). - In
finally, stop the slot and delete thePIPELINE_AUTH_TOKENapp 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
DefaultDB 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 efscaffolds againstDefaultinCargonerds.DbMigrator/appsettings.json(LocalDB by default), while the running migrator uses the layered Aspire/Azure configuration. Point your localDefaultat the right DB before scaffolding. - Status states are
NotStarted/InProgress/Completed/Failed. The JSON field isstate(the App Service script also readsexception); don't expect aStartedstate. --enable-apichanges 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
CargonerdsTenantDatabaseMigrationHandlernever 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).
Related pages¶
- Entity Framework Core — the three
DbContexts, conventions, repositories. - Caching — the Hub second-level cache and
DbContextPool. - .NET Aspire Integration — how the migrator fits the AppHost graph.
- ABP Patterns —
ReplaceDbContext, design-time factory, schema migrator. - CI/CD pipelines and Azure App Service — the migration handshake in deployment.
- Authentication — the OpenIddict clients seeded by the migrator.