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:
| Item | Convention |
|---|---|
| Folder | Apps\*\Model\Migrations\ |
| Migration class | PascalCase, descriptive name |
| Name examples | Init, AddTableTrackedChanges, LastLoginDate |
| Snapshot | ApplicationDbContextModelSnapshot.cs |
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.
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.
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(...).
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.
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.
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.
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);
}
}
Call FakeDataSeeder behind an environment check, such as ApplicationInformation.IsDevelopment, to avoid inserting demo data in production.
Best practices
| Topic | Recommendation |
|---|---|
| Idempotence | Check whether the data already exists before inserting it. |
| Transactions | Use a transaction when an intermediate SaveChangesAsync(...) occurs within a composite operation. |
| Test data | Put development data in FakeDataSeeder. |
| Production | Prefer a validated SQL script over a direct database update. |
| Collation | Create a migration after any collation change. |