Skip to main content

EF Core migrations and seeders

This page builds on the CLI basics described in Updating the database. It focuses on Lagoon conventions: migration location, naming, the EF Core snapshot, applying migrations at startup, and data initialization.

Location and naming​

The EF Core migrations of a Lagoon application live in the Model project:

ItemConvention
FolderApps\*\Model\Migrations\
Migration classPascalCase, descriptive name
Name examplesInit, AddTableTrackedChanges, LastLoginDate
SnapshotApplicationDbContextModelSnapshot.cs
Generated snapshot

ApplicationDbContextModelSnapshot.cs is generated by EF Core. Never edit it manually.

Creating and applying a migration​

The basic commands are detailed in the getting-started page. When the solution contains separate projects, explicitly target the Model project and the Server startup project.

EF Core CLI
dotnet ef migrations add <Name> --project Model\APP.Model.csproj --startup-project Server\APP.Server.csproj
dotnet ef database update --project Model\APP.Model.csproj --startup-project Server\APP.Server.csproj

The application context typically provides a Configure(...) method used by both the server and the DesignTimeDbContextFactory for migrations. This factory in the Model project derives from DesignTimeApplicationDbContextFactory<T>. The base class loads the configuration from the Server project (appsettings.json, appsettings.Development.json, user secrets, environment variables, and ${Key} placeholders) and prepares a DbContextOptionsBuilder with the same Identity options as at runtime. The factory only needs to configure the provider and instantiate the context.

Model/Context/DesignTimeDbContextFactory.cs
public class DesignTimeDbContextFactory : DesignTimeApplicationDbContextFactory<ApplicationDbContext>
{
protected override ApplicationDbContext CreateDbContext(DesignTimeCreateDbContextEventArgs<ApplicationDbContext> args)
{
// Reuse the EF Core configuration of the application context with the loaded configuration.
ApplicationDbContext.Configure(args.Builder, args.Configuration, true);
return new ApplicationDbContext(args.Builder.Options);
}
}

To store the connection string, or only its password, outside the repository, see Secrets and configuration placeholders.

Applying migrations at startup​

On the server side, OnStartAsync(...) can call UpdateDatabaseAsync(...). This method applies pending migrations and then triggers OnUpdateDatabaseAsync(...).

Main.cs
protected override async Task OnStartAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken)
{
if (Configuration.GetValue("AutoUpdateDatabase", true))
{
// Applies pending migrations then calls OnUpdateDatabaseAsync.
await UpdateDatabaseAsync(scopedServiceProvider, cancellationToken);
}
}

protected override async Task OnUpdateDatabaseAsync(IServiceProvider scopedServiceProvider, ApplicationDbContext db, CancellationToken cancellationToken)
{
// Initializes reference data after the schema update.
UserManager<ApplicationUser> userManager = scopedServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
await DataSeeder.SeedAsync(db, userManager, cancellationToken);

if (ApplicationInformation.IsDevelopment)
{
// Adds test data only in development.
await FakeDataSeeder.SeedAsync(db, cancellationToken);
}
}

DataSeeder​

DataSeeder holds the data required for the application to run, or its reference data. It must be idempotent: check for existence before inserting so it can be replayed without creating duplicates.

DataSeeder.cs
public static class DataSeeder
{
public static async Task SeedAsync(ApplicationDbContext db, UserManager<ApplicationUser> userManager, CancellationToken cancellationToken)
{
// Creates the initial administrator account only if no user exists.
if (!await userManager.Users.AnyAsync(cancellationToken))
{
ApplicationUser newAdmin = new()
{
Id = Guid.NewGuid(),
UserName = "admin",
EmailConfirmed = true,
Email = "demo-admin@lagoon.fr",
FirstName = "Admin",
LastName = "Demo",
};
await userManager.CreateAsync(newAdmin, "Test$1234");
await userManager.AddToRoleAsync(newAdmin, Roles.Admin);
await userManager.AddToRoleAsync(newAdmin, Roles.User);
}
}
}

Lagoon also exposes the IDatabaseSeeder contract with the SeedAsync(CancellationToken cancellationToken) method, for scenarios where the seeder is registered in dependency injection.

IDatabaseSeeder.cs
public interface IDatabaseSeeder
{
Task SeedAsync(CancellationToken cancellationToken);
}

FakeDataSeeder​

FakeDataSeeder is used for demo or local test data. Lagoon conventions recommend Bogus for generating development datasets.

FakeDataSeeder.cs
public static class FakeDataSeeder
{
public static async Task SeedAsync(ApplicationDbContext db, CancellationToken cancellationToken)
{
// Generate or insert test data here, then persist the changes.
await db.SaveChangesAsync(cancellationToken);
}
}
Environment

Call FakeDataSeeder behind an environment check, such as ApplicationInformation.IsDevelopment, to avoid inserting demo data in production.

Best practices​

TopicRecommendation
IdempotenceCheck whether the data already exists before inserting it.
TransactionsUse a transaction when an intermediate SaveChangesAsync(...) occurs within a composite operation.
Test dataPut development data in FakeDataSeeder.
ProductionPrefer a validated SQL script over a direct database update.
CollationCreate a migration after any collation change.