Multi-Tenanted Entity Framework Core Migration Deployment

by Chad
Published April 11, 2021
Last updated April 11, 2021

Waves

There's many ways to deploy pending Entity Framework Core (EF Core) migrations, especially for multi-tenanted scenarios. In this post, I'll demonstrate a strategy to efficiently apply pending EF Core 5 migrations using a .NET 5 console app.

Previous Article: Entity Framework 6 Migration Deployment

I recently created a post that demonstrated a strategy to deploy Entity Framework 6 (EF6) migrations using a console app. Now that EF Core has become the preferred ORM these days, I also wanted to see how this strategy looked using EF Core 5.

Code

You can find the code for this EF Core example here.

Multi-Tenanted Databases

Just like my post using EF6, for the purposes of this post, multi-tenanted refers to each tenant having its own database or connection string that's compatible with a single DbContext.

Applying EF Core Migrations with a .NET 5 Console App

There's several ways to apply pending migrations, but for this post I'll be creating a .NET 5 console app that's dedicated to applying migrations to several tenant databases.

Main Method

My console app's Main method outlines what I want to accomplish.

static async Task<int> Main(string[] args)
{
    List<MigratorTenantInfo> tenants = GetConfiguredTenants();

    IEnumerable<Task> tasks = tenants.Select(t => MigrateTenantDatabase(t));
    try
    {
        Logger.Information("Starting parallel execution of pending migrations...");
        await Task.WhenAll(tasks);
    }
    catch
    {
        Logger.Warning("Parallel execution of pending migrations is complete with error(s).");
        return (int)ExitCode.Error;
    }

    Logger.Information("Parallel execution of pending migrations is complete.");
    return (int)ExitCode.Success;
}

I want to 1) get a list of my tenants from configuration, 2) execute the migrations for each of these tenants, and 3) tell the console app runner, whether that's my host OS or the CI/CD pipeline, whether all the migrations were applied successfully or an error was encountered.

I'm able to apply each set of tenant migrations in parallel by creating a list of Task's, where each Task is the asynchronous MigrateTenantDatabase method.

Getting the Configured Tenants

To gather information about each tenant, I have an appSettings.json file like this:

{
  "MigratorTenantInfo": [
    {
      "Name": "Default",
      "ConnectionString": "Server=(LocalDb)\\MSSQLLocalDB;Database=DefaultEfCoreContext;Trusted_Connection=True;"
    },
    {
      "Name": "ExtremeGolf",
      "ConnectionString": "Server=(LocalDb)\\MSSQLLocalDB;Database=EfCoreDbContextExtremeGolf;Trusted_Connection=True;"
    },
...

My GetConfiguredTenants method yields a strongly typed list of tenants:

private static List<MigratorTenantInfo> GetConfiguredTenants()
{
    var builder = new ConfigurationBuilder()
        .SetBasePath(Directory.GetCurrentDirectory())
        .AddJsonFile("appSettings.json", optional: false);

    IConfiguration config = builder.Build();

    return config.GetSection(nameof(MigratorTenantInfo)).Get<List<MigratorTenantInfo>>();
}

Migrating Each Tenant Database

private static async Task MigrateTenantDatabase(MigratorTenantInfo tenant)
{
    using var logContext = LogContext.PushProperty("TenantName", $"({tenant.Name}) ");
    DbContextOptions dbContextOptions = CreateDefaultDbContextOptions(tenant.ConnectionString);
    try
    {
        using var context = new EfCoreDbContext(dbContextOptions);
        await context.Database.MigrateAsync();
    }
    catch (Exception e)
    {
        Logger.Error(e, "Error occurred during migration");
        throw;
    }
}

private static DbContextOptions CreateDefaultDbContextOptions(string connectionString) => 
    new DbContextOptionsBuilder()
        .LogTo(action: Logger.Information, filter: MigrationInfoLogFilter(), options: DbContextLoggerOptions.None)
        .UseSqlServer(connectionString)
        .Options;

To migrate a tenant database, I create a DbContextOptions with the tenant database connection string, and do some plumbing with the logging setup. I'll explain more about the logging later.

Using the DbContextOptions instance I created, I instantiate a new DbContext, in my case the EfCoreDbContext, and call the asynchronous method of my context's Database property, MigrateAsync().

If an error is encountered during the migration, I'll catch the exception, log the error to the console, and propagate the exception back up the caller.

Logging

While the console app is running and applying the migrations, I want to know what's happening in real time. EF Core offers a bit of logging of its internals out of the box. However, for applying migrations, I found the logging too excessive for my use case. My solution was to supply the LogTo method within the DbContextOptionsBuilder some additional parameters for filtering the logging output.

.LogTo(action: Logger.Information, filter: MigrationInfoLogFilter(), options: DbContextLoggerOptions.None)

Logger.Information

I've configured the console app to use Serilog. I set up an ILogger instance as follows.

static readonly ILogger Logger = Log.Logger = new LoggerConfiguration()
    .Enrich.FromLogContext()
    .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {TenantName}{Message:lj}{NewLine}{Exception}")
    .CreateLogger();

You may have noticed above in the MigrateTenantDatabase method, I pushed the property "TenantName" to the static LogContext. This is so I can associate the logging output to a specific tenant. This was especially important to me in order to make sense of the logging output when the migrations could be running in parallel.

My Logger.Information method is what I passed as my logging action delegate to the LogTo method.

Filtering with MigrationInfoLogFilter()

I want my logging output to only contain information about the migration in progress at the informational level. Otherwise, the only other logging output I want to know about needs to be above the informational level.

private static Func<EventId, LogLevel, bool> MigrationInfoLogFilter() => (eventId, level) =>
    level > LogLevel.Information ||
    (level == LogLevel.Information &&
    new[]
    {
        RelationalEventId.MigrationApplying,
        RelationalEventId.MigrationAttributeMissingWarning,
        RelationalEventId.MigrationGeneratingDownScript,
        RelationalEventId.MigrationGeneratingUpScript,
        RelationalEventId.MigrationReverting,
        RelationalEventId.MigrationsNotApplied,
        RelationalEventId.MigrationsNotFound,
        RelationalEventId.MigrateUsingConnection
    }.Contains(eventId));

I'm able to filter the specific events by supplying my desired EventId's from EF Core's RelationEventId static class. The result of this method is what I pass to LogTo.

Conclusion

And that about wraps it up. Here's my output after running the console app.

[15:21:40 INF] Starting parallel execution of pending migrations...
[15:21:42 INF] (ExtremeGolf) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (Hole19) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (AlbatrossWasHere) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (AugustaWho) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (BirdiesRUs) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (HeadcoverCentral) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (Default) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (BirdiesRUs) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (Hole19) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (HeadcoverCentral) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (ExtremeGolf) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (AugustaWho) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (Default) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (AlbatrossWasHere) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] Parallel execution of pending migrations is complete.

Seven tenant databases migrated in under two seconds; I can't complain!

Happy Coding!

Read Next

Multi-Tenanted Entity Framework 6 Migration Deployment image

April 10, 2021 by Chad

Multi-Tenanted Entity Framework 6 Migration Deployment

There's many ways to deploy pending Entity Framework 6 (EF6) migrations, especially for multi-tenanted production scenarios. In this post, I'll demonstrate a strategy to efficiently apply pending migrations using a .NET 5 console app.

Read Article
Entity Framework 6 vs Entity Framework Core 3: Comparing Performance image

December 02, 2019 by Chad

Entity Framework 6 vs Entity Framework Core 3: Comparing Performance

Entity Framework (EF) Core was a complete rewrite from the tried and tested EF6. One of the most touted benefits EF Core has over EF6 is improved performance. Using real benchmarks, I will use worked examples to demonstrate whether Entity Framework 6 or Entity Framework Core performs the best.

Read Article
Entity Framework Performance: 3 Things You Must Consider image

July 20, 2019 by Chad

Entity Framework Performance: 3 Things You Must Consider

I hear it all the time: Entity Framework is slow, Entity Framework can't handle this kind of volume, We need to rip out Entity Framework for regular SQL. In some cases this is necessary, but let me demonstrate a few easy ways to make sure you're eeking the most performance out of your Entity Framework queries.

Read Article