.NET Aspire showed up in the .NET 8 timeframe as Microsoft’s answer to a problem most teams building distributed .NET applications already know well. Local development environments get complicated fast once you add a database, a message broker, a cache, and two or three services that all need to talk to each other. I recently moved a real multi-service application off Docker Compose and onto Aspire orchestration, and this article walks through what worked well, what did not, and where I would think twice before adopting it on a large system.
If you have not touched Aspire yet, the short version is this: it is an opinionated orchestration layer plus a set of integration packages that wire up observability, health checks, and service discovery for you. It ships as part of the standard .NET tooling now, not as a separate framework you bolt on afterward.
The Docker Compose Starting Point
Before Aspire, my setup for a content platform project looked like a fairly typical docker-compose.yml file. It defined two APIs, a client application, PostgreSQL, and RabbitMQ, and it worked fine, but every new service meant editing YAML, wiring environment variables by hand, and keeping connection strings in sync across containers.
services:
contentplatform-api:
image: ${DOCKER_REGISTRY-}contentplatform-api
container_name: ContentPlatform.Api
build:
context: .
dockerfile: ContentPlatform.Api/Dockerfile
ports:
- 5000:8080
- 5001:8081
contentplatform-db:
image: postgres:latest
container_name: ContentPlatform.Db
environment:
- POSTGRES_DB=contentplatform
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- 5432:5432
contentplatform-mq:
image: rabbitmq:management
container_name: ContentPlatform.RabbitMq
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
This only shows three of the five services from the full setup, but the pattern is clear. Nothing here is wrong exactly, it is just manual. Connection strings for Postgres and RabbitMQ need to be typed correctly in every service that consumes them, and if you rename a container, you have to hunt down every reference to the old name across the file.
Setting Up Aspire Orchestration
Migrating an existing solution to Aspire starts with right-clicking a project in Visual Studio and choosing Add, then .NET Aspire Orchestrator Support. You repeat that for every project you want under orchestration. This adds two new projects to your solution: an AppHost project, which owns orchestration, and a ServiceDefaults project, which every enlisted service references for shared configuration.

The AppHost project effectively replaces the docker-compose.yml file. Instead of YAML, you describe your application stack in C#, and the same file that defines your services also wires up the connections between them.
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("contentplatform-db")
.WithPgAdmin();
var rabbitMq = builder.AddRabbitMQ("contentplatform-mq")
.WithManagementPlugin();
builder.AddProject<Projects.ContentPlatform_Api>("contentplatform-api")
.WithReference(postgres)
.WithReference(rabbitMq);
builder.AddProject<Projects.ContentPlatform_Reporting_Api>("contentplatform-reporting-api")
.WithReference(postgres)
.WithReference(rabbitMq);
builder.AddProject<Projects.ContentPlatform_Presentation>("contentplatform-presentation");
builder.Build().Run();
Compare this to the Compose file above. The WithReference calls do the job that manual environment variable wiring used to do, and connection strings get injected automatically at run time. Running this AppHost project from Visual Studio starts Postgres, RabbitMQ, and all three application projects together, and it opens the Aspire dashboard so you can see everything running in one place.

The dashboard is useful on a daily basis. You get a live view of every resource, its state, its logs, and its endpoints without switching between a terminal, Docker Desktop, and your IDE.
Where Orchestration Falls Short
The AppHost project has to reference every project it orchestrates directly. For a solution with a handful of services in one repository, that is a minor inconvenience. For a large microservices estate spread across multiple repositories or owned by different teams, it becomes a real constraint, since you cannot orchestrate a service you cannot reference.
There is a workaround using AddContainer, which lets you point Aspire at a prebuilt Docker image for an external service instead of a project reference. It solves the reference problem, but you lose the ability to debug that service from your IDE, which was one of the main reasons to move to Aspire in the first place. The ServiceDefaults project has a similar constraint, since every enlisted service needs to reference it, though for larger or multi-repository setups you can package it as a NuGet package and distribute it that way instead of a direct project reference.
Aspire Integrations and Connection Strings
The Postgres and RabbitMQ wiring in the AppHost snippet above comes from Aspire Integrations, which are NuGet packages that wrap common infrastructure dependencies such as Redis, PostgreSQL, and SQL Server. Right-click the AppHost project and choose Add, then .NET Aspire Package, to browse what is available.

Adding Redis, for example, means installing the Aspire.Hosting.Redis package and adding a couple of lines to the AppHost:
var builder = DistributedApplication.CreateBuilder(args);
var redis = builder.AddRedis("contentplatform-cache");
builder.AddProject<Projects.ContentPlatform_Api>("contentplatform-api")
.WithReference(postgres)
.WithReference(rabbitMq)
.WithReference(redis);
builder.Build().Run();
Each WithReference call generates an environment variable named after the resource. WithReference(postgres) produces ConnectionStrings__contentplatform-db, WithReference(rabbitMq) produces ConnectionStrings__contentplatform-mq, and WithReference(redis) produces ConnectionStrings__contentplatform-cache. Your application code then reads these using the resource’s logical name instead of a hardcoded connection string:
builder.Services.AddDbContext<ApplicationDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("contentplatform-db")));
This is one of the details that makes Aspire worth the switch on its own. You stop copying connection strings between appsettings files and environment variables, and the naming stays consistent across every environment the AppHost knows about.
Service Defaults and OpenTelemetry
When you enlist a project in Aspire orchestration, it wires two calls into your Program.cs automatically. AddServiceDefaults configures OpenTelemetry, health checks, and service discovery, and MapDefaultEndpoints exposes the health check endpoint.
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Other code omitted for brevity
var app = builder.Build();
app.MapDefaultEndpoints();
// Other code omitted for brevity
app.Run();
AddServiceDefaults is a normal extension method, so you can open it and add your own configuration, for example tracing for MassTransit or another messaging library your services depend on. This is where Aspire’s observability story gets genuinely useful, not just for local debugging but for understanding request flow across services.

The trace in the screenshot shows a POST request hitting the API service, publishing an ArticleCreatedEvent, and a second service consuming that message and processing it. Getting this level of tracing working manually with OpenTelemetry across multiple services usually takes real setup effort. Here it comes for free once you enlist a project. In production, you point the OTEL_EXPORTER_OTLP_ENDPOINT environment variable at your telemetry backend, and the same instrumentation carries over without code changes.
Deploying Aspire Applications
Deployment works through a manifest file, a JSON document that describes every resource in your AppHost, including services, databases, and their dependencies. You generate it with a dotnet run command against the AppHost project:
dotnet run --project ContentPlatform.AppHost\ContentPlatform.AppHost.csproj `
-- --publisher manifest --output-path ../aspire-manifest.json

Deployment tooling reads this manifest to provision infrastructure. For Azure targets, that means generating the configuration needed for Azure Container Apps, including networking, scaling rules, and monitoring, without you writing Bicep or Terraform by hand. If you are deploying to Kubernetes or a non-Azure cloud, the tooling support today is thinner, and you should expect to do more of that work yourself.
Is .NET Aspire Worth Adopting
After using it on a real project for a while, I think Aspire earns its place for teams building distributed .NET applications, particularly ones targeting Azure. Defining an entire application stack in a few dozen lines of C#, with automatic connection string wiring and observability built in, replaces a lot of manual Docker Compose and OpenTelemetry setup that used to eat a day or two on every new project.
That said, the two limitations around project references are not small. Large microservice systems that span multiple repositories will run into the AppHost reference requirement quickly, and the AddContainer workaround costs you debugging support for that service. Aspire is production ready now, but the surrounding ecosystem, especially for non-Azure deployment targets, is still catching up. If you are running a handful of services in one solution and deploying to Azure, give it a real try. If your architecture is larger and more fragmented, or your cloud target is not Azure, evaluate the deployment story carefully before committing.
Leave a Reply