---
title: A migration worker for EF Core in Aspire
date: 2025-02-04
tags: dotnet, ef-core, aspire, sql-server
summary: Run migrations, SQL scripts and seeding in a small worker that exits, and let every app wait for it with WaitForCompletion.
---

I don't want my web app to migrate the database on startup. With several hosts (web, API, background jobs) you never know who goes first, and every one of them would need schema rights. So the migrations get their own project: a small worker that runs once, does its job and exits. The idea comes from the official Aspire sample.

## Where the migrations live

The `DbContext` lives in the Domain project. The migrations don't:

```text title="solution"
src/
  App.AppHost/                  Aspire orchestration
  App.Domain/                   Entities, DbContext
  App.Domain.MigrationService/
      Migrations/               EF migrations live here
      Scripts/                  Raw SQL scripts
      Seeds/                    Seeders
      Worker.cs                 Migrate, run scripts, seed, exit
  App.Web / App.Api / ...
```

The Domain project stays free of migration code and of `Microsoft.EntityFrameworkCore.Design`. Only the worker references it:

```xml title="App.Domain.MigrationService.csproj"
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
```

EF needs to know where to look, so the context registration sets the migrations assembly:

```csharp title="registration"
options.UseSqlServer(o => o.MigrationsAssembly("App.Domain.MigrationService"));
```

Creating a migration then points both `--project` and `--startup-project` at the worker:

```sh title="terminal"
dotnet ef migrations add AddSomething --project src/App.Domain.MigrationService --startup-project src/App.Domain.MigrationService --context ApplicationDbContext
```

## The worker

It's a plain worker service. It uses the same `AddApplicationDbContext()` extension as every other host, so it sees exactly the same model:

```csharp title="Program.cs"
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.AddApplicationDbContext("database");
builder.Services.AddHostedService<Worker>();
builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddSource(Worker.ActivitySourceName));
await builder.Build().RunAsync();
```

The worker does four steps and then stops the application:

```csharp title="Worker.cs"
protected override async Task ExecuteAsync(CancellationToken ct)
{
    using var activity = s_activitySource.StartActivity("Migrating database", ActivityKind.Client);
    try
    {
        using var scope = serviceProvider.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

        await EnsureDatabaseAsync(db, ct);
        await RunMigrationAsync(db, ct);
        await RunSqlScriptsAsync(db, ct);
        await SeedDataAsync(db, ct);
    }
    catch (Exception ex)
    {
        activity?.AddException(ex);
        Environment.ExitCode = -1; // so the AppHost sees a failure
        throw;
    }
    hostApplicationLifetime.StopApplication();
}
```

The exit code matters. The AppHost decides whether to start the apps based on it.

## Wrap every step in the execution strategy

Aspire turns on a retrying execution strategy for SQL Server. That's good for transient errors, but it refuses multi-step work it can't retry as a whole. So every step runs inside `CreateExecutionStrategy().ExecuteAsync(...)`:

```csharp title="Worker.cs"
private static async Task EnsureDatabaseAsync(ApplicationDbContext db, CancellationToken ct)
{
    var creator = db.GetService<IRelationalDatabaseCreator>();
    var strategy = db.Database.CreateExecutionStrategy();
    await strategy.ExecuteAsync(async () =>
    {
        if (!await creator.ExistsAsync(ct))
            await creator.CreateAsync(ct);
    });
}

private static async Task RunMigrationAsync(ApplicationDbContext db, CancellationToken ct)
{
    var strategy = db.Database.CreateExecutionStrategy();
    await strategy.ExecuteAsync(async () => await db.Database.MigrateAsync(ct));
}
```

## Raw SQL scripts

Some third-party components ship their schema as a `.sql` file, not as EF migrations. Those go into `Scripts/` and are copied to the output:

```xml title="App.Domain.MigrationService.csproj"
<None Update="Scripts\*.sql" CopyToOutputDirectory="PreserveNewest" />
```

The catch is `GO`. It's a batch separator for SSMS and sqlcmd, not T-SQL, so the server doesn't understand it. Split on it first:

```csharp title="Worker.cs"
private static async Task RunSqlScriptsAsync(ApplicationDbContext db, CancellationToken ct)
{
    var dir = Path.Combine(AppContext.BaseDirectory, "Scripts");
    foreach (var file in Directory.GetFiles(dir, "*.sql").Order())
    {
        var sql = await File.ReadAllTextAsync(file, ct);
        var batches = Regex.Split(sql, @"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)
            .Where(b => !string.IsNullOrWhiteSpace(b));

        var strategy = db.Database.CreateExecutionStrategy();
        await strategy.ExecuteAsync(async () =>
        {
            foreach (var batch in batches)
                await db.Database.ExecuteSqlRawAsync(batch, ct);
        });
    }
}
```

The worker runs on every start, so the scripts must be idempotent. Guard everything with `IF NOT EXISTS`.

## Seeding

Seeders are static async methods that check before they insert. Base data is always seeded. Test data only when the config says so:

```csharp title="Worker.cs"
await BaseDataSeeder.SeedAsync(db, ct);
if (configuration.GetValue<bool>("SeedTestData"))
    await TestDataSeeder.SeedAsync(db, ct);
```

`SeedTestData=true` is only set for dev and test environments.

## AppHost

Locally SQL Server runs in a container. When publishing, it's Azure SQL:

```csharp title="AppHost.cs"
IResourceBuilder<IResourceWithConnectionString> db;
if (builder.ExecutionContext.IsPublishMode)
{
    db = builder.AddAzureSqlServer("sql")
        .AddDatabase("database", "appdb");
}
else
{
    db = builder.AddSqlServer("sql", port: 7433)
        .WithDataVolume()
        .WithLifetime(ContainerLifetime.Persistent)
        .AddDatabase("database", "appdb");
}

var migrations = builder.AddProject<Projects.App_Domain_MigrationService>("migrations")
    .WithReference(db)
    .WaitFor(db);

builder.AddProject<Projects.App_Web>("web")
    .WithReference(db)
    .WaitFor(db)
    .WaitForCompletion(migrations);
```

- `WithLifetime(ContainerLifetime.Persistent)` and `WithDataVolume()` keep the container and its data between debug sessions. Startup is fast, and your test data is still there.
- The fixed port `7433` keeps the connection string stable, so SSMS or Azure Data Studio can connect without looking it up every time.
- In production, Azure SQL uses Entra ID auth (built into `Microsoft.Data.SqlClient`), so there's no password in any connection string.

## The trap: WaitFor vs WaitForCompletion

The web app uses `WaitForCompletion(migrations)`, not `WaitFor(migrations)`.

`WaitFor` only waits until the resource is *running*. For a worker that's almost immediately, so the web app starts against a database that is still being migrated. `WaitForCompletion` waits until the process has exited with code 0. If the worker fails, the apps don't start at all, and the dashboard shows you why.

> TIL: for a run-once project in Aspire, use `WaitForCompletion`. `WaitFor` only means "it has started".
