Skip to content
Slicekit

Backend guides

Adding a database migration

Create, apply and review EF Core migrations against the AppDbContext, and how migrations run on startup.

View .md
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 update step 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:

  1. Add a migration with additive-only changes (new columns nullable, new tables).
  2. Deploy. The old code keeps running, but the schema is ready.
  3. Land the code change that uses the new shape.
  4. 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>.cs and read the generated Up and Down: the SQL should match what you intended.

Conventions to keep

  • Name action-first in PascalCase. AddUserDisplayName, not Migration3.
  • Additive before destructive. Add nullable, deploy, backfill, then tighten or drop.
  • Forward-only on main. Fix a bad migration with a new one, never remove after merge.
  • Map in configurations, not attributes. Mappings live under Persistence/Configurations/.
  • Quote Postgres identifiers in any raw migrationBuilder.Sql(...).