.NET Aspire: Bulut-Native Uygulamaları Doğru Şekilde Oluşturma
If you’ve ever built a distributed application in .NET, you know the drill. You spin up a Web API, add a background worker, throw in Redis for caching, PostgreSQL for persistence, maybe RabbitMQ for messaging — and suddenly you’re spending more time wiring infrastructure than writing business logic. Connection strings scattered across appsettings.json files, health checks you forgot to configure, observability that’s always “next sprint’s problem.”
I’ve been there. More times than I’d like to admit.
That’s exactly the problem .NET Aspire was designed to solve. After running it in production for several months now, I want to share what I’ve learned — the good, the great, and the gotchas.
What Is .NET Aspire?
.NET Aspire is an opinionated stack for building observable, production-ready, distributed applications with .NET. It’s not a framework in the traditional sense — it doesn’t replace ASP.NET Core or force you into a new programming model. Instead, it sits on top of what you already know and fills the gaps that have always existed when building cloud-native apps.
At its core, Aspire gives you four things:
- AppHost — A project that defines your entire distributed application topology. Which services exist, what they depend on, and how they connect.
- Service Defaults — A shared project that configures cross-cutting concerns like health checks, resilience policies, and OpenTelemetry — once, for all your services.
- Components — NuGet packages that provide standardized integrations with backing services like Redis, PostgreSQL, RabbitMQ, Azure Storage, and more.
- Developer Dashboard — A real-time UI that shows logs, traces, and metrics for your entire distributed app during local development.
The philosophy is simple: if every .NET cloud app needs these things, why are we all implementing them from scratch every time?
Setting Up Your First Aspire Project
Getting started is straightforward. You’ll need .NET 8 or later and the Aspire workload installed:
dotnet workload update
dotnet workload install aspire
Now create a new Aspire starter project:
dotnet new aspire-starter -n MyCloudApp
This generates a solution with four projects:
MyCloudApp.AppHost— The orchestratorMyCloudApp.ServiceDefaults— Shared configurationMyCloudApp.ApiService— A sample Web APIMyCloudApp.Web— A Blazor frontend
Run the AppHost and you’ll immediately see the Aspire dashboard open in your browser, showing all your services, their logs, and their health. No Docker Compose file. No manual port management. It just works.
dotnet run --project MyCloudApp.AppHost
That first experience is what hooked me. In under a minute, you have a fully orchestrated distributed app with observability baked in.
The AppHost Pattern
The AppHost is where the magic lives. It’s a small console application that uses a builder pattern to define your distributed application’s topology — what resources exist and how services connect to them.
Here’s what a realistic AppHost looks like:
var builder = DistributedApplication.CreateBuilder(args);
// Infrastructure resources
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin()
.AddDatabase("catalogdb");
var redis = builder.AddRedis("cache");
var rabbitmq = builder.AddRabbitMQ("messaging")
.WithManagementPlugin();
// Application services
var catalogApi = builder.AddProject<Projects.CatalogApi>("catalog-api")
.WithReference(postgres)
.WithReference(redis)
.WithReference(rabbitmq);
var orderApi = builder.AddProject<Projects.OrderApi>("order-api")
.WithReference(postgres)
.WithReference(rabbitmq);
var frontend = builder.AddProject<Projects.WebFrontend>("frontend")
.WithExternalHttpEndpoints()
.WithReference(catalogApi)
.WithReference(orderApi);
builder.Build().Run();
Read that code out loud. It practically documents itself. “The catalog API references PostgreSQL, Redis, and RabbitMQ. The frontend references the catalog API and order API.” That’s your architecture, expressed in code.
A few things worth noting:
WithReferencedoes the heavy lifting. It automatically injects connection strings and service URLs into the consuming project via environment variables and configuration. Your services don’t need to know where Redis is running — Aspire handles it.WithPgAdmin()andWithManagementPlugin()spin up admin UIs for PostgreSQL and RabbitMQ alongside the actual services. During development, these are invaluable.WithExternalHttpEndpoints()marks a service as externally accessible, which matters at deployment time.
Resource Configuration
You can configure resources with fine-grained control:
var postgres = builder.AddPostgres("postgres")
.WithEnvironment("POSTGRES_MAX_CONNECTIONS", "200")
.WithDataVolume("postgres-data")
.AddDatabase("catalogdb");
var redis = builder.AddRedis("cache")
.WithDataVolume("redis-data");
Data volumes ensure your local development data survives container restarts. Small detail, big quality-of-life improvement.
Service Defaults: The Unsung Hero
The ServiceDefaults project is the most underrated part of Aspire. It’s a shared library that every service in your solution references, and it configures all the cross-cutting concerns you’d otherwise forget or implement inconsistently.
Here’s what a typical Extensions.cs in ServiceDefaults looks like:
public static class Extensions
{
public static IHostApplicationBuilder AddServiceDefaults(
this IHostApplicationBuilder builder)
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
return builder;
}
public static IHostApplicationBuilder ConfigureOpenTelemetry(
this IHostApplicationBuilder builder)
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddGrpcClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
public static IHostApplicationBuilder AddDefaultHealthChecks(
this IHostApplicationBuilder builder)
{
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(),
["live"]);
return builder;
}
}
Then in each service’s Program.cs, one line does it all:
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Your service-specific configuration...
var app = builder.Build();
app.MapDefaultEndpoints();
app.Run();
That single AddServiceDefaults() call gives you:
- OpenTelemetry with structured logging, metrics, and distributed tracing
- Health checks with liveness and readiness endpoints
- Service discovery so services can find each other by name
- Resilience policies on all outgoing HTTP calls (retries, circuit breakers, timeouts)
This is the stuff that separates a “works on my machine” demo from a production-ready system. And Aspire makes it the default, not an afterthought.
Aspire Components
Aspire components are NuGet packages that standardize how your services connect to backing infrastructure. They’re more than just client libraries — they include health checks, logging, tracing, and configurable resilience out of the box.
Redis
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddRedisDistributedCache("cache");
That’s it. The connection string comes from the AppHost via WithReference. The component registers an IDistributedCache backed by Redis, with health checks and OpenTelemetry instrumentation already wired up.
You can also use Redis for output caching:
builder.AddRedisOutputCache("cache");
PostgreSQL with Entity Framework Core
builder.AddNpgsqlDbContext<CatalogDbContext>("catalogdb", settings =>
{
settings.DisableRetry = false;
});
This registers your DbContext with a connection to the catalogdb database defined in the AppHost. It includes connection pooling, health checks, and retry policies.
RabbitMQ
builder.AddRabbitMQClient("messaging");
Registers an IConnection from the RabbitMQ client library, fully configured and health-checked.
Azure Integrations
Aspire also has first-class components for Azure services:
// Azure Blob Storage
builder.AddAzureBlobClient("blobs");
// Azure Service Bus
builder.AddAzureServiceBusClient("servicebus");
// Azure Key Vault
builder.AddAzureKeyVaultClient("secrets");
The pattern is always the same: one line in the AppHost to define the resource, one line in the consuming service to use it. Connection details flow automatically.
The Developer Dashboard
The Aspire dashboard is one of those features that seems like a nice-to-have until you actually use it — then you can’t imagine working without it.
When you run your AppHost locally, the dashboard launches and gives you:
- Resource overview — All your services and infrastructure at a glance, with status indicators
- Structured logs — Real-time log streaming from every service, filterable and searchable
- Distributed traces — End-to-end request traces spanning multiple services, visualized as flame charts
- Metrics — HTTP request rates, error rates, latencies, and custom metrics in real-time
- Console logs — Raw stdout/stderr from each container and project
The distributed tracing is particularly valuable. When a request hits your frontend, flows through the catalog API, touches Redis and PostgreSQL — you see the entire chain with timing for each hop. No more guessing where the bottleneck is.
I’ve found the dashboard most useful during debugging. Instead of tailing multiple terminal windows or jumping between log files, everything is in one place with correlation IDs linking related events across services.
The dashboard is also available as a standalone container, which means you can use it in staging environments or CI pipelines:
docker run --rm -p 18888:18888 \
mcr.microsoft.com/dotnet/aspire-dashboard:latest
Deployment
Aspire helps during development, but what about production? This is where things get practical.
Azure Container Apps
The most straightforward deployment path is Azure Container Apps (ACA), which has first-class Aspire support. You can deploy directly using the Azure Developer CLI:
azd init
azd up
The azd init command detects your Aspire AppHost and generates the necessary infrastructure-as-code. azd up provisions everything — container registry, container apps, databases, Redis instances — based on your AppHost topology.
Your AppHost essentially becomes your deployment manifest. The same code that defines “catalog-api depends on PostgreSQL and Redis” drives the infrastructure provisioning.
Kubernetes
For Kubernetes deployments, Aspire doesn’t generate manifests directly, but your AppHost topology maps cleanly to Kubernetes resources. The community tool aspirate (Aspir8) can generate Helm charts or Kubernetes manifests from your AppHost:
dotnet tool install -g aspirate
aspirate generate
aspirate apply
Deployment Considerations
A few things to keep in mind:
- Connection strings change between environments. Locally, Aspire spins up containers and injects connection strings automatically. In production, you’ll point to managed services. Aspire uses standard .NET configuration, so environment variables and Azure Key Vault work as expected.
- The AppHost doesn’t run in production. It’s a development and deployment orchestration tool. In production, your services run independently, configured through environment variables and orchestrators.
- Infrastructure resources become managed services. Your local PostgreSQL container becomes Azure Database for PostgreSQL. Your local Redis container becomes Azure Cache for Redis. The consuming code doesn’t change.
Real-World Tips
After running Aspire in production for a while, here are the lessons that saved us time:
1. Use Custom Resource Lifecycle Checks
Don’t just rely on container startup to determine if a resource is ready. PostgreSQL might accept TCP connections before it’s actually ready to serve queries.
var postgres = builder.AddPostgres("postgres")
.AddDatabase("catalogdb")
.WithHealthCheck();
Aspire can run health checks against resources and hold dependent services until they’re actually ready.
2. Extract Common Patterns Into Extensions
If multiple services share similar configurations, create extension methods:
public static class AppHostExtensions
{
public static IResourceBuilder<ProjectResource> AddWorkerService(
this IDistributedApplicationBuilder builder,
string name,
IResourceBuilder<IResourceWithConnectionString> db,
IResourceBuilder<IResourceWithConnectionString> messaging)
{
return builder.AddProject<Projects.WorkerService>(name)
.WithReference(db)
.WithReference(messaging)
.WithReplicas(3);
}
}
3. Leverage WithReplicas for Load Testing
var catalogApi = builder.AddProject<Projects.CatalogApi>("catalog-api")
.WithReference(postgres)
.WithReference(redis)
.WithReplicas(5);
WithReplicas spins up multiple instances of a service locally. This is great for testing load balancing, concurrency bugs, and distributed caching behavior without deploying to a cluster.
4. Use Parameters for Sensitive Values
Don’t hardcode credentials, even for local development:
var dbPassword = builder.AddParameter("db-password", secret: true);
var postgres = builder.AddPostgres("postgres", password: dbPassword)
.AddDatabase("catalogdb");
When running locally, Aspire will prompt for the value or read it from user secrets. In CI/CD, it comes from environment variables.
5. Integration Tests Become Trivial
Aspire includes a testing package that makes integration testing distributed apps remarkably easy:
[Fact]
public async Task CatalogApiReturnsProducts()
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyCloudApp_AppHost>();
await using var app = await appHost.BuildAsync();
await app.StartAsync();
var httpClient = app.CreateHttpClient("catalog-api");
var response = await httpClient.GetAsync("/api/products");
response.EnsureSuccessStatusCode();
var products = await response.Content
.ReadFromJsonAsync<List<Product>>();
Assert.NotEmpty(products);
}
This spins up your entire distributed application — including databases and message brokers — runs your test against it, and tears it down. Real integration tests against real infrastructure, in your CI pipeline. No mocks.
6. Monitor Resource Usage Locally
When running multiple containers locally, keep an eye on resource consumption. A PostgreSQL, Redis, and RabbitMQ instance with management UI can easily consume 2-3 GB of RAM. If you’re on a constrained machine, consider using lighter resource configurations:
var redis = builder.AddRedis("cache")
.WithContainerRuntimeArgs("--memory=256m");
Conclusion
.NET Aspire has fundamentally changed how I build distributed applications. Not because it introduces revolutionary concepts — health checks, OpenTelemetry, and container orchestration aren’t new. But because it makes them default. It takes the hundred small decisions you’d normally have to make, implements them with sensible defaults, and lets you override when needed.
The AppHost pattern is, in my opinion, the biggest win. Having your distributed application topology expressed as code — not scattered across Docker Compose files, Kubernetes manifests, and README documents — makes the system comprehensible. New team members can open Program.cs in the AppHost and understand the entire architecture in minutes.
If you’re building distributed applications with .NET, give Aspire a serious look. Start with the aspire-starter template, explore the dashboard, and gradually adopt components as you need them. You don’t have to go all-in on day one — Aspire is additive by design.
The days of spending your first sprint wiring up infrastructure boilerplate are over. Let Aspire handle the plumbing so you can focus on what actually matters: your application.