Mert Özen Further With Every Line
Backend .NET Microservices in Practice — #02

The Solution Skeleton: Aspire AppHost, ServiceDefaults and a One-Command Local Environment

Mert Özen Nov 14, 2026 14 min 3 views
The Solution Skeleton: Aspire AppHost, ServiceDefaults and a One-Command Local Environment

An Aspire AppHost describes Bookstore's five services, a SQL Server database per service, Redis, RabbitMQ and Keycloak in one C# file and runs them with a single command; ServiceDefaults adds telemetry, health checks, service discovery and resilience to every service with one line. We run migrations as a separate resource by starting the service binary with --migrate and wait for it with WaitForCompletion; real start times show why WaitFor isn't enough.

Version note: The code in this post was built and run with .NET 10, Aspire 13.6 (Aspire.Hosting.Keycloak 13.6 preview), Entity Framework Core 10 and OpenTelemetry .NET 1.21; the containers are SQL Server 2025, Redis 8.6, RabbitMQ 4.3 and Keycloak 26.6.

Guess First

In this post we run Catalog's migrations in a separate process, as a resource called catalog-migrator. The Catalog service has to wait for that process. Aspire has two methods for waiting: WaitFor(catalogMigrator) and WaitForCompletion(catalogMigrator). If you use the first one, when does Catalog start: when the migrator starts, or when the migrator finishes and exits? And if the migrator exits because of an error, what happens to Catalog? We will see the answer to both questions in a real run, with the start times of the resources.

Adding the AppHost and ServiceDefaults to the Repository

In post 01 we split Bookstore, the online bookstore used as the shared example across this site's series, into five empty services. Now we build the local environment that brings those services, the databases and the broker up with a single command. What Aspire is, its resource model and the details of the dashboard are the topic of the Distributed Applications with Aspire series; here we only look at how we set it up in Bookstore.

Aspire brings two projects. The AppHost is a console application that describes the whole system in C# and runs it during development. ServiceDefaults is a small library every service references: it gathers OpenTelemetry, health check, service discovery and resilience settings into one extension method. We added both to the existing repository with templates:

dotnet new install Aspire.ProjectTemplates::13.6.5
dotnet new aspire-apphost -o src/Bookstore.AppHost -n Bookstore.AppHost
dotnet new aspire-servicedefaults -o src/BuildingBlocks/Bookstore.ServiceDefaults -n Bookstore.ServiceDefaults
dotnet sln add src/Bookstore.AppHost src/BuildingBlocks/Bookstore.ServiceDefaults

The Aspire CLI's aspire init command does the same job, but in our try it put the AppHost into a folder at the repository root, didn't add it to the solution and didn't create ServiceDefaults. Since we fixed the folder layout in post 01, using the templates directly was less work. We put ServiceDefaults under src/BuildingBlocks; it's one of the two shared projects the architecture test from post 01 allows.

The templates write package versions into the .csproj. Because Central Package Management is on in the repository, we moved the versions to Directory.Packages.props; instead of the OpenTelemetry 1.17 and Microsoft.Extensions.Http.Resilience 10.10 the template brings, we used the latest stable versions on NuGet (1.21 and 10.11). The AppHost itself is an MSBuild SDK:

<Project Sdk="Aspire.AppHost.Sdk/13.6.5">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <AspireUseCliBundle>true</AspireUseCliBundle>
    <UserSecretsId>bookstore-apphost</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.Keycloak" />
    <PackageReference Include="Aspire.Hosting.RabbitMQ" />
    <PackageReference Include="Aspire.Hosting.Redis" />
    <PackageReference Include="Aspire.Hosting.SqlServer" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Services\Catalog\Bookstore.Catalog.Api\Bookstore.Catalog.Api.csproj" />
    <ProjectReference Include="..\Services\Inventory\Bookstore.Inventory.Grpc\Bookstore.Inventory.Grpc.csproj" />
    <ProjectReference Include="..\Services\Notifications\Bookstore.Notifications.Api\Bookstore.Notifications.Api.csproj" />
    <ProjectReference Include="..\Services\Ordering\Bookstore.Ordering.Api\Bookstore.Ordering.Api.csproj" />
    <ProjectReference Include="..\Services\Payments\Bookstore.Payments.Api\Bookstore.Payments.Api.csproj" />
  </ItemGroup>

</Project>

The AppHost's references to the service projects aren't normal project references: the Aspire SDK generates types such as Projects.Bookstore_Catalog_Api from them, and the AppHost doesn't call the service's code, it only starts it as a separate process. That's why the "a service can't reference another service" rule from post 01 doesn't apply to the AppHost; the test only looks under src/Services anyway. UserSecretsId is an important detail: the passwords Aspire generates are written there, as we will see shortly.

Infrastructure Resources: SQL Server, Redis, RabbitMQ and Keycloak

The first half of AppHost.cs describes the containers:

IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);

// One SQL Server container locally, one database per service
IResourceBuilder<SqlServerServerResource> sql = builder.AddSqlServer("sql")
    .WithImageTag("2025-latest")
    .WithContainerName("msapp-sql")
    .WithDataVolume("msapp-sql-data")
    .WithLifetime(ContainerLifetime.Persistent);

IResourceBuilder<SqlServerDatabaseResource> catalogDb = sql.AddDatabase("catalogdb");
IResourceBuilder<SqlServerDatabaseResource> orderingDb = sql.AddDatabase("orderingdb");
IResourceBuilder<SqlServerDatabaseResource> inventoryDb = sql.AddDatabase("inventorydb");
IResourceBuilder<SqlServerDatabaseResource> paymentsDb = sql.AddDatabase("paymentsdb");

IResourceBuilder<RedisResource> redis = builder.AddRedis("redis")
    .WithContainerName("msapp-redis");

IResourceBuilder<RabbitMQServerResource> rabbitmq = builder.AddRabbitMQ("rabbitmq")
    .WithContainerName("msapp-rabbitmq")
    .WithManagementPlugin()
    .WithDataVolume("msapp-rabbitmq-data")
    .WithLifetime(ContainerLifetime.Persistent);

builder.AddKeycloak("keycloak", 8180)
    .WithContainerName("msapp-keycloak")
    .WithDataVolume("msapp-keycloak-data")
    .WithLifetime(ContainerLifetime.Persistent);

Every line has a reason. WithImageTag("2025-latest"): Aspire 13.6's default SQL Server image is 2022-latest; because Bookstore uses SQL Server 2025, we set the tag explicitly. For RabbitMQ (rabbitmq:4.3-management), Redis (redis:8.6) and Keycloak (quay.io/keycloak/keycloak:26.6) we stay with the images Aspire picks. RabbitMQ's 4.4 series came out in October 2026, but 4.3 has community support until January 2027; waiting until Aspire changes its default isn't a problem.

ContainerLifetime.Persistent: by default Aspire starts and stops containers together with the AppHost. Setting up SQL Server, RabbitMQ and Keycloak from scratch on every start takes minutes, especially the first time. A persistent container keeps running when the AppHost stops and is reused on the next aspire run. We deliberately left Redis with a session lifetime: we keep nothing permanent in it, and a clean start every time suits us. WithDataVolume puts the data into a named volume; even if the container is deleted, the databases and Keycloak's settings remain. WithContainerName gives the containers fixed names, so docker ps --filter name=msapp- shows the ones that belong to this series in one command.

We gave Keycloak a fixed host port (8180). Aspire's Keycloak integration documentation recommends a stable port too: browser session cookies are tied to the port, and if the port changes on every start, the login flow we build in post 10 breaks every time. A warning: the Aspire.Hosting.Keycloak package is still in preview at the time of writing (13.6.5-preview.1). It works fine for local development; we proceed assuming that in production Keycloak is set up by its own deployment method, not by Aspire.

We didn't write the passwords. Aspire generates a password parameter for SQL Server, Redis, RabbitMQ and Keycloak and, in an AppHost with a UserSecretsId, stores them in user secrets. The ports assigned to persistent containers are kept in the same place; because the password and port don't change on the second start, the existing container can be reconnected. These are the keys in the secrets file after the first run (values hidden):

Parameters:sql-password
Parameters:redis-password
Parameters:rabbitmq-password
Parameters:keycloak-password
Resources:sql:tcp:port
Resources:rabbitmq:tcp:port
Resources:rabbitmq:management:port
Resources:keycloak:management:port
AppHost:OtlpApiKey
AppHost:DashboardApiKey

This file isn't in the repository but in the user profile (on Windows, %APPDATA%\Microsoft\UserSecrets\bookstore-apphost). Every team member gets different passwords and none of them is committed. If you want to change a password, you have to delete the persistent volume too; SQL Server and Keycloak keep the admin password they wrote on first start in the volume.

Connecting the Services: WithReference, WaitFor and Health Checks

The second half describes the services:

// The migrator is the Catalog binary started with --migrate; it exits when the schema is up to date
IResourceBuilder<ProjectResource> catalogMigrator = builder
    .AddProject<Projects.Bookstore_Catalog_Api>("catalog-migrator", options => options.ExcludeLaunchProfile = true)
    .WithArgs("--migrate")
    .WithEnvironment("Seed__SampleData", "true")
    .WithReference(catalogDb)
    .WaitFor(catalogDb);

builder.AddProject<Projects.Bookstore_Catalog_Api>("catalog")
    .WithReference(catalogDb)
    .WaitForCompletion(catalogMigrator)
    .WithHttpHealthCheck("/health");

builder.AddProject<Projects.Bookstore_Ordering_Api>("ordering")
    .WithReference(orderingDb)
    .WaitFor(orderingDb)
    .WithHttpHealthCheck("/health");

builder.AddProject<Projects.Bookstore_Inventory_Grpc>("inventory")
    .WithReference(inventoryDb)
    .WaitFor(inventoryDb)
    .WithHttpHealthCheck("/health");

builder.AddProject<Projects.Bookstore_Payments_Api>("payments")
    .WithReference(paymentsDb)
    .WaitFor(paymentsDb)
    .WithHttpHealthCheck("/health");

builder.AddProject<Projects.Bookstore_Notifications_Api>("notifications")
    .WithReference(redis)
    .WithReference(rabbitmq)
    .WaitFor(redis)
    .WaitFor(rabbitmq)
    .WithHttpHealthCheck("/health");

builder.Build().Run();

WithReference(catalogDb) gives Catalog the connection string of catalogdb as an environment variable. The value we saw in the running Catalog process (password hidden):

ConnectionStrings__catalogdb = Server=127.0.0.1,28595;User ID=sa;Password=<hidden>;TrustServerCertificate=true;Initial Catalog=catalogdb
OTEL_SERVICE_NAME = catalog

In post 01 we said "only the owner gets the connection string"; the AppHost is where that rule is enforced. Ordering doesn't get a reference to catalogdb, so it never sees its connection string. In code review, every WithReference line in AppHost.cs should be read as a dependency decision between two services. WaitFor determines the start order: Ordering doesn't start until orderingdb is healthy (container up, database created). WithHttpHealthCheck("/health") connects the service's own health endpoint to the dashboard; this line is what makes the difference between "Running" and "Healthy".

The services are still almost empty, but all of them got these three lines:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

WebApplication app = builder.Build();

app.MapDefaultEndpoints();
app.MapGet("/", () => "Bookstore.Ordering.Api");

app.Run();

ServiceDefaults: What We Don't Repeat in Every Service

AddServiceDefaults() comes from the Extensions.cs the template generates. We made only two small changes to this file (an explicit type instead of var, and deleting the Azure Monitor lines we don't use). The important part is this:

public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
    builder.ConfigureOpenTelemetry();

    builder.AddDefaultHealthChecks();

    builder.Services.AddServiceDiscovery();

    builder.Services.ConfigureHttpClientDefaults(http =>
    {
        // The standard resilience pipeline on every HttpClient
        http.AddStandardResilienceHandler();

        // Service discovery on every HttpClient: addresses like http://catalog are resolved
        http.AddServiceDiscovery();
    });

    return builder;
}

There are four jobs. OpenTelemetry: logs, metrics and traces go over OTLP to OTEL_EXPORTER_OTLP_ENDPOINT; Aspire fills that variable with the dashboard's address. Health checks: /health runs all checks, /alive only the ones tagged live; the template maps both only in the Development environment, and deliberately exposing them in production is a topic for post 14. Service discovery: in post 05 Ordering will find Inventory at https+http://inventory. Resilience: every HttpClient gets the standard pipeline with retry, timeout and circuit breaker; we covered its settings in the Standard Pipeline post of the External Service Integration and Resilience series.

What doesn't go into ServiceDefaults is a decision too. Database, broker and authentication settings don't go here; each service adds those itself, according to its own needs. As long as ServiceDefaults stays limited to "infrastructure that must be the same in every service", it doesn't break the shared code policy from post 01. The day a business rule or a DTO goes in, it turns into a Bookstore.Common project, whatever its name is.

Running Migrations in a Separate Process

We moved Catalog's data model from the monolith as is: Book, Author, Category, now in catalogdb and in their own CatalogDbContext. Shaping the model for the service is the topic of post 03; the question here is when and where the migration runs. Calling Database.Migrate() at application startup was acceptable in a single-instance monolith. When several instances start at the same time, when a migration takes long or when it fails, that approach causes trouble: health checks turn red before the application is up, Kubernetes restarts the pod and the migration can be cut off halfway.

There are three ways in Aspire. The first, recommended by the documentation, is a separate Worker Service project per database: it applies the migration and exits with StopApplication. The second is AddEFMigrations from the Aspire.Hosting.EntityFrameworkCore package: it manages migrations from the AppHost, but the package is in preview at the time of writing. The third is the one we chose: starting the service's own binary with a --migrate argument, as a separate resource. The migration code and the DbContext stay in the service, we don't add an extra project, and in post 14 we can run the same image with a different argument as a Job in Kubernetes. The price is a small branch in the service's Program.cs:

using Bookstore.Catalog.Data;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddSqlServerDbContext<CatalogDbContext>("catalogdb");

WebApplication app = builder.Build();

if (DatabaseMigration.IsMigrationRun(args))
{
    await app.MigrateDatabaseAsync();
    return;
}

app.MapDefaultEndpoints();
app.MapGet("/", () => "Bookstore.Catalog.Api");

app.Run();
public static class DatabaseMigration
{
    // The same binary also runs as a short-lived migrator: dotnet run -- --migrate
    public static bool IsMigrationRun(string[] args) => args.Contains("--migrate");

    public static async Task MigrateDatabaseAsync(this WebApplication app)
    {
        CancellationToken ct = app.Lifetime.ApplicationStopping;
        Stopwatch elapsed = Stopwatch.StartNew();

        await using AsyncServiceScope scope = app.Services.CreateAsyncScope();
        CatalogDbContext db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();

        string[] pending = [.. await db.Database.GetPendingMigrationsAsync(ct)];
        await db.Database.MigrateAsync(ct);
        app.Logger.LogInformation("Applied {Count} migration(s): {Migrations}", pending.Length, string.Join(", ", pending));

        if (app.Configuration.GetValue<bool>("Seed:SampleData"))
        {
            await CatalogSeed.SeedAsync(db, ct);
            app.Logger.LogInformation("Sample data ready: {Books} books", await db.Books.CountAsync(ct));
        }

        app.Logger.LogInformation("catalogdb is up to date in {Elapsed} ms", elapsed.ElapsedMilliseconds);
    }
}

Calling app.Build() doesn't start Kestrel; since we never reach app.Run(), the migrator doesn't listen on a port. That's why ExcludeLaunchProfile = true is on the AppHost side: port 5101 in the launch profile belongs to the real Catalog, and the migrator shouldn't take it. AddSqlServerDbContext is Aspire's EF Core client integration; it registers the DbContext and adds a health check named after the context type, tracing, metrics and retries with a default of 6. The retries keep the migrator from failing while the SQL Server container isn't accepting connections yet.

The sample data (the 20 classics from post 01) doesn't come inside the migration with HasData; it comes from a separate CatalogSeed.SeedAsync and runs only when Seed:SampleData is on. The AppHost turns it on with WithEnvironment("Seed__SampleData", "true"); the production environment doesn't have this variable, so demo books never get into the production database. We generated the migration as usual; dotnet ef comes from the tool manifest in the repository (dotnet tool restore):

dotnet ef migrations add InitialCreate --project src/Services/Catalog/Bookstore.Catalog.Api --output-dir Data/Migrations

Lines we picked from the migrator's log on the first run:

[catalog-migrator] info: Microsoft.EntityFrameworkCore.Migrations[20411]
[catalog-migrator]       Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this
[catalog-migrator]       EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive';
[catalog-migrator] info: Microsoft.EntityFrameworkCore.Migrations[20402]
[catalog-migrator]       Applying migration '20261114122715_InitialCreate'.
[catalog-migrator]       EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session';
[catalog-migrator]       Applied 1 migration(s): 20261114122715_InitialCreate
[catalog-migrator]       Sample data ready: 20 books
[catalog-migrator]       catalogdb is up to date in 915 ms

The sp_getapplock line is the migration lock that came with EF Core 9. If two migrators run at the same time, the second waits for the first to finish; it's the feature we covered in the What's New in EF Core 9 post of the Entity Framework Core series. On the second start the same migrator writes this:

[catalog-migrator]       No migrations were applied. The database is already up to date.
[catalog-migrator]       Applied 0 migration(s):
[catalog-migrator]       Sample data ready: 20 books
[catalog-migrator]       catalogdb is up to date in 844 ms

WaitFor or WaitForCompletion? Real Start Times

We tried the question from Guess First. We started Catalog once with WaitFor(catalogMigrator) and once with WaitForCompletion(catalogMigrator), and read the start and stop times of the resources from the output of aspire describe --format Json:

WaitFor(catalogMigrator)
  catalog-migrator  start 12:51:39.995  stop 12:51:43.881
  catalog           start 12:51:40.058

WaitForCompletion(catalogMigrator)
  catalog-migrator  start 12:52:28.048  stop 12:52:29.412  exit 0
  catalog           start 12:52:29.921

With WaitFor, Catalog started 63 ms after the migrator started; the migration took another 3.8 seconds. Because the migrator has no health check, reaching the "Running" state is enough for WaitFor. With a migration that adds a new column, Catalog's first requests would hit the old schema and get an Invalid column name error. With WaitForCompletion, Catalog started half a second after the migrator exited with code 0. The Aspire documentation also shows WaitForCompletion for migrations.

For the second question we broke the migrator on purpose: we set Seed__SampleData to yes, which GetValue<bool> can't convert. The result:

[catalog-migrator] Unhandled exception. System.InvalidOperationException: Failed to convert configuration value 'yes' at 'Seed:SampleData' to type
[catalog-migrator]  ---> System.FormatException: yes is not a valid value for Boolean.

migrator Finished exit 134 | catalog FailedToStart

When the migrator exits with a non-zero code, Catalog is never started and shows up on the dashboard as FailedToStart. A service that doesn't run instead of a service that runs against a half-finished schema: that's the behavior we want.

aspire run: Everything Green on the Dashboard

The whole system starts with one command. We ran the commands with the http launch profile; because this profile opens the dashboard without HTTPS, the launch profile needs ASPIRE_ALLOW_UNSECURED_TRANSPORT=true, otherwise the AppHost stops at startup with the error "The 'applicationUrl' setting must be an https address". With the default https profile, the Aspire CLI tries to create and trust the development certificate at startup; if that step fails, it suggests the aspire certs trust command.

aspire run -lp http

     AppHost:  src/Bookstore.AppHost/Bookstore.AppHost.csproj

   Dashboard:  http://localhost:15001/login?t=<token>

               Press CTRL+C to stop the AppHost and exit.

Instead of opening the dashboard in a browser, you can also query it from the Aspire CLI. aspire describe finds the running AppHost and lists the resources (we shortened the dashboard link column):

aspire describe

Name               Type                       State     Health   URLs
catalog            Project                    Running   Healthy  http://localhost:5101
catalog-migrator   Project                    Finished  -        -
catalogdb          SqlServerDatabaseResource  Running   Healthy  -
inventory          Project                    Running   Healthy  http://localhost:5103
inventorydb        SqlServerDatabaseResource  Running   Healthy  -
keycloak           Container                  Running   Healthy  http://localhost:8180
notifications      Project                    Running   Healthy  http://localhost:5105
ordering           Project                    Running   Healthy  http://localhost:5102
orderingdb         SqlServerDatabaseResource  Running   Healthy  -
payments           Project                    Running   Healthy  http://localhost:5104
paymentsdb         SqlServerDatabaseResource  Running   Healthy  -
rabbitmq           Container                  Running   Healthy  http://localhost:28597, tcp://localhost:28596
redis              Container                  Running   Healthy  redis://localhost:61117
sql                Container                  Running   Healthy  tcp://localhost:28595

Fourteen resources: five services, one migrator, four databases and four containers. The migrator's state is "Finished" and its Health column is empty; that's the right picture for a short-lived job. Catalog's health endpoint also answers directly; the health check of CatalogDbContext is part of that answer:

curl -i http://localhost:5101/health

HTTP/1.1 200 OK
Content-Type: text/plain
Date: Sat, 14 Nov 2026 12:37:16 GMT
Server: Kestrel
Cache-Control: no-store, no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Pragma: no-cache
Transfer-Encoding: chunked

Healthy

We measured the timings too. With empty volumes, the first start took a few minutes because of SQL Server's and Keycloak's first-time setup. With the persistent containers running, the second aspire run showed every resource as "Healthy" in 15 seconds, build included. What's left when we stop the AppHost:

docker ps --filter name=msapp- --format "table {{.Names}}\t{{.Status}}"

NAMES            STATUS
msapp-keycloak   Up 7 minutes
msapp-rabbitmq   Up 7 minutes
msapp-sql        Up 7 minutes

Redis is gone because it had a session lifetime; the other three wait for the next start. Because they keep using memory until you shut the machine down, stopping them at the end of the day with docker stop msapp-sql msapp-rabbitmq msapp-keycloak is a good habit.

For Teams Without Aspire: Docker Compose

If your team doesn't use Aspire, you can build the same environment with Docker Compose; Compose's details are in the Docker Compose series. There's a bridge between the two as well: after adding the Aspire.Hosting.Docker package and writing builder.AddDockerComposeEnvironment("compose") in the AppHost, aspire publish produces a docker-compose.yaml and a .env file. We tried it in a copy; in the generated file, WaitForCompletion became Compose's condition: "service_completed_successfully" under depends_on, the migrator's argument became command: ["--migrate"], and the passwords became variables in .env such as SQL_PASSWORD. WaitFor(catalogDb), on the other hand, became only condition: "service_started": on the Compose side, EF Core's retries cover SQL Server not being ready yet. We didn't run the generated file, because the service images have to be built first; image building is the topic of post 14.

In Production

The AppHost is a development tool. In production, containers, passwords and connections are set up by Kubernetes manifests, a secret store and the deployment pipeline; the model you write in the AppHost can be a source for them, but it doesn't replace them. Keycloak's local setup in particular (a preview integration, the admin password in a volume) isn't an example for production.

Make the migration a separate step of the deployment. The order we built locally with WaitForCompletion means "migration Job first, then the new version" in production. To be able to do that, migrations must be backward compatible: a new column is added and the old code keeps working; dropping a column waits for the next version. The Where Migrations Fit in a Deployment post of the Deployment Strategies series covers this order in detail.

Watch the resource usage of the local environment. When we measured, the four containers used 1.6 GB of memory in total (SQL Server alone 910 MB, Keycloak 540 MB); the .NET processes, including the services, the AppHost and the dashboard, were around 2 GB as well. If the memory you give Docker Desktop is below 8 GB, consider removing Keycloak from the AppHost until you need it, or making it a resource you start by hand with WithExplicitStart().

Common Pitfalls

Using WaitFor for a migrator. As we saw above, the service starts while the migrator is running. For short-lived jobs the right method is WaitForCompletion.

Starting the migrator with its launch profile. Without ExcludeLaunchProfile, two resources try to use the same launch profile and therefore the same port. The migrator doesn't need a port.

Changing the password of a persistent container and forgetting the volume. Aspire tries to connect with the new password while SQL Server expects the old password from the volume; the result is a login error. Changing the password means deleting the volume.

Giving every service a reference to everything. WithReference is a declaration of a dependency. Giving Notifications a reference to catalogdb opens the way for someone to run "a small query" against it tomorrow.

Bloating ServiceDefaults. A logging enricher, a shared exception handler, then shared DTOs... ServiceDefaults should stay the infrastructure that must be the same in every service.

A Small Experiment

★☆☆ Run the AppHost, then stop RabbitMQ with docker stop msapp-rabbitmq. What happens to the state of Notifications and RabbitMQ in aspire describe? What changes when you start the container again?

★★☆ Make Redis ContainerLifetime.Persistent as well and start the AppHost twice. Which new key was added to the user secrets file? Write one sentence about when Redis being persistent would help in this series (think of Notifications in post 11).

★★★ Build the same migrator setup for Ordering: a single Order table in OrderingDbContext, a --migrate branch, an ordering-migrator resource and WaitForCompletion. When the two migrators run at the same time, does the __EFMigrationsLock lock affect them? Why?

The local environment is ready: one command, fourteen resources, all green. In the next post we turn Catalog into a real service: its own data model, paging and search endpoints, output cache, API versioning, a contract published with ProblemDetails and OpenAPI, and the BookPriceChanged and BookRenamed events that Ordering will listen to in post 06.