Skip to main content
Masar Eagle uses FluentMigrator for database migrations with PostgreSQL. The migration system is automated, version-controlled, and runs automatically on service startup.

Migration Architecture

Components

BaseMigration

Abstract base class with helper methods for safe schema changes

DatabaseMigrationService

Background service that ensures database exists and runs migrations

MigrationExtensions

DI registration for FluentMigrator

BaseMigration Class

All migrations inherit from BaseMigration, which provides helper methods for safe, idempotent operations. Location: src/BuildingBlocks/MasarEagle.Migrations/BaseMigration.cs
BaseMigration.cs
public abstract class BaseMigration : Migration
{
    protected abstract string ServiceName { get; };

    // Create table with standard audit columns
    protected void CreateTableWithStandardColumns(
        string tableName, 
        Action<ICreateTableColumnOptionOrWithColumnSyntax>? additionalColumns = null)
    {
        if (!Schema.Table(tableName).Exists())
        {
            var table = Create.Table(tableName)
                .WithColumn("Id").AsString(50).PrimaryKey().NotNullable()
                .WithColumn("CreatedAt").AsDateTime().NotNullable()
                    .WithDefault(SystemMethods.CurrentDateTime)
                .WithColumn("UpdatedAt").AsDateTime().NotNullable()
                    .WithDefault(SystemMethods.CurrentDateTime)
                .WithColumn("IsDeleted").AsBoolean().NotNullable()
                    .WithDefaultValue(false)
                .WithColumn("DeletedAt").AsDateTime().Nullable();

            additionalColumns?.Invoke(table);
        }
    }

    // Create index if columns exist
    protected void CreateIndexIfNotExists(
        string tableName, 
        string[] columnNames, 
        bool isUnique = false)
    {
        bool allColumnsExist = columnNames.All(col => 
            Schema.Table(tableName).Column(col).Exists());
        
        if (!allColumnsExist) return;
        
        string indexName = $"IX_{tableName}_{string.Join("_", columnNames)}";
        string uniqueKeyword = isUnique ? "UNIQUE" : "";
        string columnList = string.Join(", ", 
            columnNames.Select(c => $"\"{c}\" ASC"));
        
        Execute.Sql($@"
            CREATE {uniqueKeyword} INDEX IF NOT EXISTS ""{indexName}""
            ON ""{tableName}" ({columnList});
        ");
    }

    // Add column only if it doesn't exist
    protected void AddColumnIfNotExists(
        string tableName, 
        string columnName, 
        Action<IAlterTableAddColumnOrAlterColumnOrSchemaOrDescriptionSyntax> columnDefinition)
    {
        if (Schema.Table(tableName).Exists() && 
            !Schema.Table(tableName).Column(columnName).Exists())
        {
            var syntax = Alter.Table(tableName);
            columnDefinition(syntax);
        }
    }
}

DatabaseMigrationService

Automated background service that:
  1. Waits for PostgreSQL to be ready (with retries)
  2. Creates the database if it doesn’t exist
  3. Runs all pending migrations
  4. Logs progress with structured logging
Location: src/BuildingBlocks/MasarEagle.Migrations/DatabaseMigrationService.cs
DatabaseMigrationService.cs:18-40
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    logger.LogInformation("Starting database migration service...");

    try
    {
        await WaitForDatabaseAsync(stoppingToken);

        await RunMigrationsAsync();

        logger.LogInformation("Database migrations completed successfully");
    }
    catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
    {
        logger.LogWarning("Database migration cancelled");
        throw;
    }
    catch (Exception ex)
    {
        logger.LogCritical(ex, "Database migration failed. Application cannot start.");
        throw;
    }
}

Creating a Migration

1. Create Migration File

Migrations are located in src/services/{ServiceName}/{ServiceName}.Api/Infrastructure/Migrations/. Naming Convention: {YYYYMMDD}{SequenceNumber}_{Description}.cs Examples:
  • 202501250012_AddLocationCoordinatesToTrips.cs
  • 202502010001_CreateCompanyTripsTable.cs

2. Implement Migration

using FluentMigrator;
using MasarEagle.Migrations;

namespace Trips.Api.Infrastructure.Migrations;

[Migration(202501250012)]
public class AddLocationCoordinatesToTrips : BaseMigration
{
    protected override string ServiceName => "Trips";

    public override void Up()
    {
        // Check before adding each column
        if (!Schema.Table("trips").Column("from_latitude").Exists())
        {
            Create.Column("from_latitude")
                .OnTable("trips")
                .AsDecimal(10, 8)
                .Nullable();
        }

        if (!Schema.Table("trips").Column("from_longitude").Exists())
        {
            Create.Column("from_longitude")
                .OnTable("trips")
                .AsDecimal(11, 8)
                .Nullable();
        }

        if (!Schema.Table("trips").Column("from_address").Exists())
        {
            Create.Column("from_address")
                .OnTable("trips")
                .AsString(500)
                .Nullable();
        }

        Console.WriteLine("Added location coordinates to trips table");
    }

    public override void Down()
    {
        if (Schema.Table("trips").Column("from_address").Exists())
        {
            Delete.Column("from_address").FromTable("trips");
        }

        if (Schema.Table("trips").Column("from_longitude").Exists())
        {
            Delete.Column("from_longitude").FromTable("trips");
        }

        if (Schema.Table("trips").Column("from_latitude").Exists())
        {
            Delete.Column("from_latitude").FromTable("trips");
        }
    }
}

3. Register Migrations

Migrations are automatically registered via assembly scanning in Program.cs:
Program.cs
using System.Reflection;
using MasarEagle.Constants.Aspire;
using MasarEagle.Migrations;

builder.Services.AddDatabaseMigrations(
    builder.Configuration,
    Assembly.GetExecutingAssembly(),  // Scan current assembly
    connectionStringName: Components.Database.Trip);

Migration Patterns

Idempotent Operations

Always check if schema elements exist before creating them.
// Good: Idempotent
if (!Schema.Table("trips").Column("currency").Exists())
{
    Create.Column("currency")
        .OnTable("trips")
        .AsString(10)
        .Nullable();
}

// Bad: Will fail if column exists
Create.Column("currency")
    .OnTable("trips")
    .AsString(10)
    .Nullable();

Foreign Keys

Use BaseMigration.CreateForeignKeyIfNotExists() for safe FK creation.
CreateForeignKeyIfNotExists(
    tableName: "bookings",
    columnName: "TripId",
    referencedTable: "trips",
    referencedColumn: "Id",
    onDelete: System.Data.Rule.Cascade);

Data Migrations

For data transformations, use Execute.Sql().
public override void Up()
{
    // Add column
    AddColumnIfNotExists("trips", "currency", table =>
        table.AddColumn("currency")
            .AsString(10)
            .Nullable());

    // Migrate data
    Execute.Sql(@"
        UPDATE trips 
        SET currency = 'SAR' 
        WHERE currency IS NULL;
    ");

    // Make column non-nullable
    Alter.Table("trips")
        .AlterColumn("currency")
        .AsString(10)
        .NotNullable();
}

Dropping Tables Safely

Use helper method from BaseMigration.
public override void Down()
{
    DeleteTableIfExists("company_trips");
}

Migration Versioning

Migration versions are long integers: YYYYMMDD## (date + sequence)
[Migration(202501250012)]  // January 25, 2025, 12th migration that day
Never reuse or modify migration version numbers. Once a migration runs in any environment, its version is permanent.

Running Migrations

Automatic (Production)

Migrations run automatically on service startup via DatabaseMigrationService. Startup Logs:
info: MasarEagle.Migrations.DatabaseMigrationService[0]
      Starting database migration service...
info: MasarEagle.Migrations.DatabaseMigrationService[0]
      Waiting for database to be ready...
info: MasarEagle.Migrations.DatabaseMigrationService[0]
      Database is ready (attempt 1/30)
info: MasarEagle.Migrations.DatabaseMigrationService[0]
      Running database migrations...
info: MasarEagle.Migrations.DatabaseMigrationService[0]
      Found 3 pending migrations
info: MasarEagle.Migrations.DatabaseMigrationService[0]
      Database migrations completed successfully

Configuration

Configure retry behavior in appsettings.json:
{
  "Migration": {
    "MaxRetries": 30,
    "RetryDelaySeconds": 5
  }
}

Manual (Development)

For development testing, you can use FluentMigrator CLI:
dotnet tool install -g FluentMigrator.DotNet.Cli

# Run migrations
dotnet fm migrate \
  --provider postgres \
  --connection "Host=localhost;Database=tripdb;Username=postgres;Password=postgres" \
  --assembly ./bin/Debug/net8.0/Trips.Api.dll

# Rollback
dotnet fm rollback \
  --provider postgres \
  --connection "Host=localhost;Database=tripdb;Username=postgres;Password=postgres" \
  --assembly ./bin/Debug/net8.0/Trips.Api.dll \
  --steps 1

Migration Testing

Test Up and Down

Always test both Up() and Down() methods.
[Fact]
public void Migration_Up_CreatesTableSuccessfully()
{
    // Arrange
    var migration = new CreateCompanyTripsTable();
    
    // Act
    migration.Up();
    
    // Assert
    Assert.True(Schema.Table("company_trips").Exists());
}

[Fact]
public void Migration_Down_DropstableSuccessfully()
{
    // Arrange
    var migration = new CreateCompanyTripsTable();
    migration.Up();
    
    // Act
    migration.Down();
    
    // Assert
    Assert.False(Schema.Table("company_trips").Exists());
}

Test Idempotency

Ensure migrations can run multiple times safely.
[Fact]
public void Migration_Up_IsIdempotent()
{
    // Arrange
    var migration = new AddLocationCoordinatesToTrips();
    
    // Act - Run twice
    migration.Up();
    migration.Up();  // Should not throw
    
    // Assert
    Assert.True(Schema.Table("trips").Column("from_latitude").Exists());
}

Common Column Types

// String columns
.AsString(50)              // VARCHAR(50)
.AsString(450)             // VARCHAR(450) - for IDs
.AsString()                // VARCHAR(255) default
.AsAnsiString(50)          // For ASCII-only data

// Numeric columns
.AsInt32()                 // INTEGER
.AsInt64()                 // BIGINT
.AsDecimal(10, 2)          // DECIMAL(10, 2) - for money
.AsBoolean()               // BOOLEAN

// Date/Time columns
.AsDateTime()              // TIMESTAMP
.AsDateTimeOffset()        // TIMESTAMPTZ (with timezone)
.AsDate()                  // DATE only

// Binary
.AsBinary()                // BYTEA

// JSON (PostgreSQL)
.AsCustom("jsonb")         // JSONB column

Best Practices

Use Schema.Table().Exists() and Schema.Table().Column().Exists() before creating schema elements.
Each migration should represent one logical change (e.g., “Add payment columns”, “Create reviews table”).
Always implement Down() for rollback capability, even if you don’t plan to use it.
FluentMigrator wraps each migration in a transaction automatically. Don’t disable this unless absolutely necessary.
Test migrations on a copy of production data to catch edge cases.
Once deployed, migrations are immutable. Create a new migration to fix issues.

Troubleshooting

Migration Fails to Run

Check logs:
docker logs trips-api | grep -i migration
Common causes:
  • Database not accessible
  • Connection string incorrect
  • Migration syntax error
  • Constraint violation

Database Connection Issues

The migration service retries up to 30 times (configurable):
warn: MasarEagle.Migrations.DatabaseMigrationService[0]
      Database not ready (attempt 5/30). Retrying in 5s...

Migration Version Conflicts

If two developers create migrations with the same version number:
  1. The second developer must rename their migration file
  2. Update the [Migration(...)] attribute with a new version
  3. Commit and push

Migration History

FluentMigrator tracks applied migrations in the VersionInfo table:
SELECT * FROM "VersionInfo" ORDER BY "Version" DESC;
VersionAppliedOnDescription
2025020100012025-02-01 10:15:23CreateCompanyTripsTable
2025012500122025-01-25 14:30:45AddLocationCoordinatesToTrips

Next Steps

Building Blocks

Learn about the migration infrastructure

Adding Features

Create features that use your new schema

Build docs developers (and LLMs) love