Backend guides
Adding a database migration
Create, apply and review EF Core migrations against the AppDbContext, and how startup migration and production bundles are configured.
On this page
Where migrations live
Schema is server-side only, so this is an API-only task with no frontend counterpart. EF Core
migrations live in api/src/Slicekit.Core/Persistence/Migrations/. The DbContext is
AppDbContext, and the startup project that supplies the design-time configuration is
Slicekit.Api. Both are required arguments to every local dotnet-ef command.
1. Create the migration
From api/, run the generator and point it at the context and startup project:
dotnet tool restore
dotnet tool run dotnet-ef migrations add <Name> \
--project src/Slicekit.Core \
--startup-project src/Slicekit.Api \
--context AppDbContext
Use PascalCase, action-first names that describe the change: AddUserDisplayName,
DropRefreshTokenUserAgent, AddConsentsSoftDeleteEncryption. EF writes a
<timestamp>_<Name>.cs file (plus its designer partial) into the Migrations/ folder.
2. How migrations are applied
Startup migration is controlled by Database:MigrateOnStartup and is off by default. The local
Development launch profile and test configuration enable it, so running the API applies pending
migrations during development and Testcontainers applies them to fresh databases in the test suite.
Permission synchronization still runs when startup migration is disabled.
The production image ships /app/efbundle-dataprotectionkeycontext and /app/efbundle-appdbcontext.
Run them in that order as a pre-deploy step with the API’s configuration, then roll out the new image
only after they succeed. The included production Compose stack instead enables startup migration
because it has no separate migration job. See deployment.
New schemas must remain compatible with the previously deployed code while a rollout is in progress.
3. Order changes around the outbox
Wolverine uses a transactional outbox in Postgres (see CQRS and events). Migrations that touch outbox-adjacent tables or sequences must land before the new code rolls out, so in-flight outbox rows still match a schema the workers can deserialize.
The safe additive pattern:
- Add a migration with additive-only changes (new columns nullable, new tables).
- Deploy. The old code keeps running, but the schema is ready.
- Land the code change that uses the new shape.
- If needed, add a second migration that tightens the schema (for example makes the column
NOT NULL) once all running code populates it.
For destructive changes (drop a column, drop a table), reverse the order: stop writing the value first, deploy, then drop.
4. Forward-only is the policy
If a migration that already shipped to main turns out wrong, add a new “undo” migration rather
than reaching for dotnet ef migrations remove after the fact. remove rewrites history and
breaks anyone who already pulled main. It is only acceptable on a branch that has not been
merged yet.
Custom SQL
Use the migration’s migrationBuilder.Sql(...) for things EF cannot model (functions, triggers,
partial indexes). Quote identifiers ("User", not User): Postgres folds unquoted identifiers to
lowercase.
migrationBuilder.Sql("""
CREATE INDEX ix_user_active_email ON "User" ("Email") WHERE "DeletedAt" IS NULL;
""");
Configure entities, do not annotate them
Do not put EF attributes on domain types. Each aggregate keeps its persistence mapping in an
IEntityTypeConfiguration<T> under api/src/Slicekit.Core/Persistence/Configurations/
(UserConfiguration, RefreshTokenConfiguration, and so on). AppDbContext.OnModelCreating
already discovers them:
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
Adding a configuration file is enough to change the model; the next migrations add picks up the
diff. This keeps the domain free of EF concerns, a rule enforced by the
Domain_Must_Not_Depend_On architecture test. The same split
between domain and persistence drives the rest of the backend; see
Vertical slices and Project structure.
Verify
dotnet build api/slicekit.slnx: passes, so the new migration compiles.dotnet test api/slicekit.slnx --nologo: passes. Feature and API tests apply all migrations against a fresh Postgres container, so a broken migration surfaces here.- Open
api/src/Slicekit.Core/Persistence/Migrations/<timestamp>_<Name>.csand read the generatedUpandDown: the SQL should match what you intended.
Conventions to keep
- Name action-first in PascalCase.
AddUserDisplayName, notMigration3. - Additive before destructive. Add nullable, deploy, backfill, then tighten or drop.
- Forward-only on
main. Fix a bad migration with a new one, neverremoveafter merge. - Map in configurations, not attributes. Mappings live under
Persistence/Configurations/. - Quote Postgres identifiers in any raw
migrationBuilder.Sql(...).