Backend guides
Adding a database migration
Create, apply and review EF Core migrations against the AppDbContext, and how migrations run on startup.
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 dotnet ef command.
1. Create the migration
From api/, run the generator and point it at the context and startup project:
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
You never run a manual database update in normal development. ApplyMigrationsAsync in
api/src/Slicekit.Core/Configuration/Database.cs calls db.Database.MigrateAsync() on the host
before the app starts serving, then syncs permissions:
public static async Task ApplyMigrationsAsync(this IHost host)
{
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
var permissionSync = scope.ServiceProvider.GetRequiredService<PermissionSyncService>();
await permissionSync.SyncAsync();
}
That single path covers three environments:
- Local dev: applied at API startup. Add the migration, then just run the API.
- Tests: Testcontainers spins up a clean Postgres per fixture and applies every migration from scratch, so a broken migration fails the suite.
- CI and production: applied at startup. You can also run a separate
dotnet ef database updatestep in your pipeline if you prefer to gate deploys on it. For zero-downtime deploys, prefer migrations that are forward-compatible with the previously deployed code.
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(...).