Edit

Share via


Apply Entity Framework Core migrations in .NET Aspire

Since .NET Aspire projects use a containerized architecture, databases are ephemeral and can be recreated at any time. Entity Framework Core (EF Core) uses a feature called migrations to create and update database schemas. Since databases are recreated when the app starts, you need to apply migrations to initialize the database schema each time your app starts. This is accomplished by registering a migration service project in your app that runs migrations during startup.

In this tutorial, you learn how to configure .NET Aspire projects to run EF Core migrations during app startup.

Prerequisites

To work with .NET Aspire, you need the following installed locally:

For more information, see .NET Aspire setup and tooling, and .NET Aspire SDK.

Obtain the starter app

This tutorial uses a sample app that demonstrates how to apply EF Core migrations in .NET Aspire. Use Visual Studio to clone the sample app from GitHub or use the following command:

Bash
git clone https://github.com/MicrosoftDocs/aspire-docs-samples/

The sample app is in the SupportTicketApi folder. Open the solution in Visual Studio or VS Code and take a moment to review the sample app and make sure it runs before proceeding. The sample app is a rudimentary support ticket API, and it contains the following projects:

  • SupportTicketApi.Api: The ASP.NET Core project that hosts the API.
  • SupportTicketApi.Data: Contains the EF Core contexts and models.
  • SupportTicketApi.AppHost: Contains the .NET Aspire app host and configuration.
  • SupportTicketApi.ServiceDefaults: Contains the default service configurations.

Run the app to ensure it works as expected. From the .NET Aspire dashboard, select the https Swagger endpoint and test the API's GET /api/SupportTickets endpoint by expanding the operation and selecting Try it out. Select Execute to send the request and view the response:

JSON
[
  {
    "id": 1,
    "title": "Initial Ticket",
    "description": "Test ticket, please ignore."
  }
]

Create migrations

Start by creating some migrations to apply.

  1. Open a terminal (Ctrl+` in Visual Studio).

  2. Set SupportTicketApiSupportTicketApi.Api as the current directory.

  3. Use the dotnet ef command-line tool to create a new migration to capture the initial state of the database schema:

    .NET CLI
    dotnet ef migrations add InitialCreate --project ..\SupportTicketApi.Data\SupportTicketApi.Data.csproj
    

    The proceeding command:

    • Runs EF Core migration command-line tool in the SupportTicketApi.Api directory. dotnet ef is run in this location because the API service is where the DB context is used.
    • Creates a migration named InitialCreate.
    • Creates the migration in the in the Migrations folder in the SupportTicketApi.Data project.
  4. Modify the model so that it includes a new property. Open SupportTicketApi.DataModelsSupportTicket.cs and add a new property to the SupportTicket class:

    C#
    public sealed class SupportTicket
    {
        public int Id { get; set; }
        [Required]
        public string Title { get; set; } = string.Empty;
        [Required]
        public string Description { get; set; } = string.Empty;
        public bool Completed { get; set; }
    }
    
  5. Create another new migration to capture the changes to the model:

    .NET CLI
    dotnet ef migrations add AddCompleted --project ..\SupportTicketApi.Data\SupportTicketApi.Data.csproj
    

Now you've got some migrations to apply. Next, you'll create a migration service that applies these migrations during app startup.

Create the migration service

To run the migrations at startup, you need to create a service that applies the migrations.

  1. Add a new Worker Service project to the solution. If using Visual Studio, right-click the solution in Solution Explorer and select Add > New Project. Select Worker Service and name the project SupportTicketApi.MigrationService. If using the command line, use the following commands from the solution directory:

    .NET CLI
    dotnet new worker -n SupportTicketApi.MigrationService
    dotnet sln add SupportTicketApi.MigrationService
    
  2. Add the SupportTicketApi.Data and SupportTicketApi.ServiceDefaults project references to the SupportTicketApi.MigrationService project using Visual Studio or the command line:

    .NET CLI
    dotnet add SupportTicketApi.MigrationService reference SupportTicketApi.Data
    dotnet add SupportTicketApi.MigrationService reference SupportTicketApi.ServiceDefaults
    
  3. Add the 📦 Aspire.Microsoft.EntityFrameworkCore.SqlServer NuGet package reference to the SupportTicketApi.MigrationService project using Visual Studio or the command line:

    .NET CLI
    dotnet add package Aspire.Microsoft.EntityFrameworkCore.SqlServer
    
  4. Add the highlighted lines to the Program.cs file in the SupportTicketApi.MigrationService project:

    C#
    using SupportTicketApi.Data.Contexts;
    using SupportTicketApi.MigrationService;
    
    var builder = Host.CreateApplicationBuilder(args);
    
    builder.AddServiceDefaults();
    builder.Services.AddHostedService<Worker>();
    
    builder.Services.AddOpenTelemetry()
        .WithTracing(tracing => tracing.AddSource(Worker.ActivitySourceName));
    
    builder.AddSqlServerDbContext<TicketContext>("sqldata");
    
    var host = builder.Build();
    host.Run();
    

    In the preceding code:

  5. Replace the contents of the Worker.cs file in the SupportTicketApi.MigrationService project with the following code:

    C#
    using System.Diagnostics;
    
    using Microsoft.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore.Infrastructure;
    using Microsoft.EntityFrameworkCore.Storage;
    
    using OpenTelemetry.Trace;
    
    using SupportTicketApi.Data.Contexts;
    using SupportTicketApi.Data.Models;
    
    namespace SupportTicketApi.MigrationService;
    
    public class Worker(
        IServiceProvider serviceProvider,
        IHostApplicationLifetime hostApplicationLifetime) : BackgroundService
    {
        public const string ActivitySourceName = "Migrations";
        private static readonly ActivitySource s_activitySource = new(ActivitySourceName);
    
        protected override async Task ExecuteAsync(CancellationToken cancellationToken)
        {
            using var activity = s_activitySource.StartActivity("Migrating database", ActivityKind.Client);
    
            try
            {
                using var scope = serviceProvider.CreateScope();
                var dbContext = scope.ServiceProvider.GetRequiredService<TicketContext>();
    
                await EnsureDatabaseAsync(dbContext, cancellationToken);
                await RunMigrationAsync(dbContext, cancellationToken);
                await SeedDataAsync(dbContext, cancellationToken);
            }
            catch (Exception ex)
            {
                activity?.RecordException(ex);
                throw;
            }
    
            hostApplicationLifetime.StopApplication();
        }
    
        private static async Task EnsureDatabaseAsync(TicketContext dbContext, CancellationToken cancellationToken)
        {
            var dbCreator = dbContext.GetService<IRelationalDatabaseCreator>();
    
            var strategy = dbContext.Database.CreateExecutionStrategy();
            await strategy.ExecuteAsync(async () =>
            {
                // Create the database if it does not exist.
                // Do this first so there is then a database to start a transaction against.
                if (!await dbCreator.ExistsAsync(cancellationToken))
                {
                    await dbCreator.CreateAsync(cancellationToken);
                }
            });
        }
    
        private static async Task RunMigrationAsync(TicketContext dbContext, CancellationToken cancellationToken)
        {
            var strategy = dbContext.Database.CreateExecutionStrategy();
            await strategy.ExecuteAsync(async () =>
            {
                // Run migration in a transaction to avoid partial migration if it fails.
                await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
                await dbContext.Database.MigrateAsync(cancellationToken);
                await transaction.CommitAsync(cancellationToken);
            });
        }
    
        private static async Task SeedDataAsync(TicketContext dbContext, CancellationToken cancellationToken)
        {
            SupportTicket firstTicket = new()
            {
                Title = "Test Ticket",
                Description = "Default ticket, please ignore!",
                Completed = true
            };
    
            var strategy = dbContext.Database.CreateExecutionStrategy();
            await strategy.ExecuteAsync(async () =>
            {
                // Seed the database
                await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
                await dbContext.Tickets.AddAsync(firstTicket, cancellationToken);
                await dbContext.SaveChangesAsync(cancellationToken);
                await transaction.CommitAsync(cancellationToken);
            });
        }
    }
    

    In the preceding code:

    • The ExecuteAsync method is called when the worker starts. It in turn performs the following steps:
      1. Gets a reference to the TicketContext service from the service provider.
      2. Calls EnsureDatabaseAsync to create the database if it doesn't exist.
      3. Calls RunMigrationAsync to apply any pending migrations.
      4. Calls SeedDataAsync to seed the database with initial data.
      5. Stops the worker with StopApplication.
    • The EnsureDatabaseAsync, RunMigrationAsync, and SeedDataAsync methods all encapsulate their respective database operations using execution strategies to handle transient errors that may occur when interacting with the database. To learn more about execution strategies, see Connection Resiliency.

Add the migration service to the orchestrator

The migration service is created, but it needs to be added to the .NET Aspire app host so that it runs when the app starts.

  1. In the SupportTicketApi.AppHost project, open the Program.cs file.

  2. Add the following highlighted code to the ConfigureServices method:

    C#
    var builder = DistributedApplication.CreateBuilder(args);
    
    var sql = builder.AddSqlServer("sql")
                     .AddDatabase("sqldata");
    
    builder.AddProject<Projects.SupportTicketApi_Api>("api")
        .WithReference(sql);
    
    builder.AddProject<Projects.SupportTicketApi_MigrationService>("migrations")
        .WithReference(sql);
    
    builder.Build().Run();
    

    This enlists the SupportTicketApi.MigrationService project as a service in the .NET Aspire app host.

    Important

    If you are using Visual Studio, and you selected the Enlist in Aspire orchestration option when creating the Worker Service project, similar code is added automatically with the service name supportticketapi-migrationservice. Replace that code with the preceding code.

Remove existing seeding code

Since the migration service seeds the database, you should remove the existing data seeding code from the API project.

  1. In the SupportTicketApi.Api project, open the Program.cs file.

  2. Delete the highlighted lines.

    C#
    if (app.Environment.IsDevelopment())
    {
        app.UseSwagger();
        app.UseSwaggerUI();
    
        using (var scope = app.Services.CreateScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<TicketContext>();
            context.Database.EnsureCreated();
    
            if(!context.Tickets.Any())
            {
                context.Tickets.Add(new SupportTicket { Title = "Initial Ticket", Description = "Test ticket, please ignore." });
                context.SaveChanges();
            }
        }
    }
    

Test the migration service

Now that the migration service is configured, run the app to test the migrations.

  1. Run the app and observe the SupportTicketApi dashboard.

  2. After a short wait, the migrations service state will display Finished.

    A screenshot of the .NET Aspire dashboard with the migration service in a Finished state.

  3. Select the View link on the migration service to investigate the logs showing the SQL commands that were executed.

Get the code

You can find the completed sample app on GitHub.

More sample code

The Aspire Shop sample app uses this approach to apply migrations. See the AspireShop.CatalogDbManager project for the migration service implementation.