Skip to main content

Creating Migrations

Database migrations manage schema evolution as your domain model changes. EF Core migrations are generated from your DbContext configurations and stored as C# files. In PearDrop, migrations are generated separately for write and read contexts.

What is a Migration?

A migration is a timestamped C# file that describes schema changes:

Data/
├── WriteModel/
│ └── Migrations/Generated/
│ ├── 20251201185932_InitialCreate.cs ← Migration file
│ ├── 20251201185932_InitialCreate.Designer.cs ← Generated helper
│ ├── 20251215092000_AddEquipmentCategory.cs ← Second migration
│ └── ModelSnapshot.cs ← Current schema state

└── ReadModel/
└── Migrations/Generated/
├── 20251201185932_InitialCreate.cs
└── ModelSnapshot.cs

File Structure:

  • Migration: Up/Down methods defining schema changes
  • Designer.cs: Metadata file (don't edit manually)
  • ModelSnapshot.cs: Records current schema state (don't edit manually)

Tool Setup

Install EF Core Tools

# Install globally (recommended)
dotnet tool install --global dotnet-ef

# Or install locally in project
dotnet tool install dotnet-ef

Verify installation:

dotnet ef --version
# Output: Entity Framework Core .NET Command-line Tools 8.0.0

Generating Migrations

Command Structure

dotnet ef migrations add <MigrationName> \
--project <ProjectPath> \
--startup-project <StartupProjectPath> \
--context <DbContextClass> \
--output-dir <OutputDirectory>

Write Model Migration

# Navigate to solution root
cd c:\path\to\solution

# Add migration for write model
dotnet ef migrations add InitialCreate \
--project ".\modules\mymodule\source\MyApp.Module\MyApp.Module.csproj" \
--startup-project ".\samples\MyApp\MyApp.csproj" \
--context MyAppWriteDbContext \
--output-dir "Data/WriteModel/Migrations/Generated"

# Add subsequent migration
dotnet ef migrations add AddEquipmentCategory \
--project ".\modules\mymodule\source\MyApp.Module\MyApp.Module.csproj" \
--startup-project ".\samples\MyApp\MyApp.csproj" \
--context MyAppWriteDbContext \
--output-dir "Data/WriteModel/Migrations/Generated"

Read Model Migration

# Add migration for read model
dotnet ef migrations add InitialCreate \
--project ".\modules\mymodule\source\MyApp.Module\MyApp.Module.csproj" \
--startup-project ".\samples\MyApp\MyApp.csproj" \
--context MyAppReadDbContext \
--output-dir "Data/ReadModel/Migrations/Generated"

Parameters:

  • --project: Path to module containing DbContext
  • --startup-project: Entry point (program.cs)
  • --context: Fully qualified DbContext class name
  • --output-dir: Where to place migration files (relative to project)

Example:Generated Migration File

// 20251201185932_InitialCreate.cs
using System;
using Microsoft.EntityFrameworkCore.Migrations;

nullable disable;

namespace PearDrop.Auth.Data.WriteModel.Migrations.Generated
{
/// <remarks>
/// This migration was generated by the EF Core tools.
/// IMPORTANT: Review this migration carefully before applying to production.
/// </remarks>
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Create tables
migrationBuilder.CreateTable(
name: "peardrop_authentication_user",
schema: "dbo",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
WhenDisabled = table.Column<DateTime>(type: "datetime2", nullable: true),
WhenVerified = table.Column<DateTime>(type: "datetime2", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_peardrop_authentication_user", x => x.Id);
});

// Create related tables
migrationBuilder.CreateTable(
name: "peardrop_authentication_authenticationHistory",
schema: "dbo",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AttemptedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Succeeded = table.Column<bool>(type: "bit", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_peardrop_authentication_authenticationHistory", x => x.Id);
table.ForeignKey(
name: "FK_peardrop_authentication_authenticationHistory_peardrop_authentication_user_UserId",
column: x => x.UserId,
principalTable: "peardrop_authentication_user",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});

// Create indexes
migrationBuilder.CreateIndex(
name: "IX_peardrop_authentication_user_TenantId",
table: "peardrop_authentication_user",
column: "TenantId");

migrationBuilder.CreateIndex(
name: "IX_peardrop_authentication_authenticationHistory_UserId",
table: "peardrop_authentication_authenticationHistory",
column: "UserId");
}

protected override void Down(MigrationBuilder migrationBuilder)
{
// Reverse operations in opposite order
migrationBuilder.DropTable(
name: "peardrop_authentication_authenticationHistory",
schema: "dbo");

migrationBuilder.DropTable(
name: "peardrop_authentication_user",
schema: "dbo");
}
}
}

Key Patterns:

  • Up(): Describes schema changes to apply (CreateTable, AddColumn, CreateIndex, etc.)
  • Down(): Reverses changes (DropTable, DropColumn, DropIndex, etc.)
  • Tables use module prefix (e.g., peardrop_authentication_user)
  • Foreign keys include module prefix in constraint naming
  • Indexes created for foreign keys and queried columns

Migration Best Practices

Do:

  • Create focused migrations for each logical change
  • Review generated migration SQL before applying
  • Use meaningful migration names (AddEquipmentStatusField, not Migration20251201)
  • Commit migrations to version control immediately
  • Test migrations in development before applying to production
  • Document complex migrations with comments in Up() method

Don't:

  • Edit generated migration files manually (regenerate instead)
  • Modify Designer.cs or ModelSnapshot.cs files
  • Skip reviewing EF-generated SQL
  • Mix multiple unrelated changes in one migration
  • Apply migrations directly to production (use CI/CD pipeline)
  • Manually modify .Designer.cs files generated by EF Core tools

Reviewing Generated SQL

To preview the actual SQL that will be executed:

# Generate and preview SQL without applying
dotnet ef migrations script \
--project ".\modules\mymodule\source\MyApp.Module" \
--startup-project ".\samples\MyApp\MyApp.csproj" \
--context MyAppWriteDbContext \
--from 20251201185932 \
--to 20251215092000 \
--output "migration-script.sql"

# Review migration-script.sql before running

This is critical for production to review schema changes with your DBA.

Common Migration Scenarios

Adding a Column

// In DbContext: Add property to entity
public class Equipment : Entity, IAggregateRoot
{
public string? ManufacturerModel { get; set; } // New property
}

// Generate migration
dotnet ef migrations add AddManufacturerModelToEquipment ^
--context MyAppWriteDbContext ^
--output-dir "Data/WriteModel/Migrations/Generated"

// Generated migration will include:
migrationBuilder.AddColumn<string>(
name: "ManufacturerModel",
table: "myapp_equipment",
type: "nvarchar(256)",
maxLength: 256,
nullable: true);

Changing Column Properties

// Modify entity configuration
builder.Property(e => e.Category)
.HasMaxLength(256); // Previously 128

// Generate migration
dotnet ef migrations add IncreaseCategory LengthToEquipment

// Generated migration updates column definition

Adding Foreign Key Relationship

// In Equipment entity - add relationship
public Guid CategoryId { get; set; }
public Category Category { get; set; } = null!;

// In configuration
builder.HasOne(e => e.Category)
.WithMany()
.HasForeignKey(e => e.CategoryId)
.OnDelete(DeleteBehavior.Restrict);

// Generate migration
dotnet ef migrations add AddCategoryForeignKeyToEquipment

Adding Index

// In configuration
builder.HasIndex(e => new { e.TenantId, e.Category })
.HasName("IX_Equipment_TenantId_Category");

// Generate migration
dotnet ef migrations add AddEquipmentTenantCategoryIndex

// Generated migration:
migrationBuilder.CreateIndex(
name: "IX_Equipment_TenantId_Category",
table: "myapp_equipment",
columns: new[] { "TenantId", "Category" });

Handling Migration Conflicts

Branch Conflicts

If two branches create migrations with same timestamp:

# Remove conflicting migration from your branch
dotnet ef migrations remove --context MyAppWriteDbContext

# Pull main branch migrations
git pull origin main

# Regenerate your migration after main's migrations
dotnet ef migrations add YourFeatureMigration --context MyAppWriteDbContext

Existing Database

If database was manually modified:

# Regenerate migrations to match current state
dotnet ef migrations add CreateFromExistingDatabase --context MyAppWriteDbContext

# Or reset and start fresh (development only!)
dotnet ef database drop --context MyAppWriteDbContext
dotnet ef database update --context MyAppWriteDbContext

CI/CD Pipeline Integration

Migrations are typically applied during deployment:

# Azure Pipelines example
- task: DotNetCoreCLI@2
displayName: 'Apply Migrations (Write)'
inputs:
command: 'custom'
custom: 'ef'
arguments: 'database update --project ./MyApp.Module --startup-project ./MyApp --context MyAppWriteDbContext'

- task: DotNetCoreCLI@2
displayName: 'Apply Migrations (Read)'
inputs:
command: 'custom'
custom: 'ef'
arguments: 'database update --project ./MyApp.Module --startup-project ./MyApp --context MyAppReadDbContext'

Next Steps