.NET Aspire: A Game-Changer for Cloud-Native Development?

.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.

Context menu with .NET Aspire Orchestrator Support highlighted in Visual Studio.
Context menu with .NET Aspire Orchestrator Support highlighted in Visual Studio.

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.

Aspire dashboard showing the running services and containers for the content platform solution.
Aspire dashboard showing the running services and containers for the content platform solution.

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.

NuGet package browser showing available .NET Aspire integration packages.
NuGet package browser showing available .NET Aspire integration packages.

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.

Aspire dashboard distributed trace showing a request flow across two services through a published event.
Aspire dashboard distributed trace showing a request flow across two services through a published event.

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
Example .NET Aspire manifest JSON file describing application resources.
Example .NET Aspire manifest JSON file describing application resources.

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

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading