On more than one project I’ve watched the first sprint disappear into Docker Compose files and appsettings drift: standing up databases, wiring service discovery, getting distributed tracing working, keeping connection strings in sync across environments. None of it is business logic, and all of it has to work before the business logic can.
I already had a Clean Architecture app running (Angular frontend, .NET API, PostgreSQL) with its own Docker Compose setup. Rather than rewrite it, I put .NET Aspire on top and let the AppHost own orchestration. The whole migration came down to two new projects, one line in Program.cs, and a project reference. This post is what I changed, in order, and what each change bought me.
Source Code: Clean Architecture with .NET Aspire
Note: This is a migration onto an existing application, not a greenfield start. The path is deliberately minimal: two new projects, one line in
Program.cs, and a project reference.
What you’ll build#
By the end you’ll have Aspire layered onto a running app, giving you:
- One
AppHost.csthat starts the Angular frontend, .NET API, PostgreSQL, and pgAdmin with a single command. - Automatic service discovery, so no hardcoded
localhost:5217URLs survive. - Connection strings injected by the orchestrator instead of copied into
appsettings.json. - OpenTelemetry traces, logs, and metrics across every service, in one dashboard.
Adding Aspire to an Existing Project#
You don’t need to start from scratch. This project shows how to add Aspire to an existing Clean Architecture application. Here’s what I added:
Project Structure Changes#
aspire/ # NEW - Aspire orchestration
├── AppHost/
│ ├── AppHost.cs # Service definitions
│ └── Contact.AppHost.csproj # AppHost project file
└── ServiceDefaults/
├── Extensions.cs # OpenTelemetry, health checks
└── Contact.ServiceDefaults.csprojSteps to Add Aspire to Your Existing Project#
Install the Aspire workload:
dotnet workload install aspireCreate the AppHost project:
dotnet new aspire-apphost -n YourApp.AppHostCreate the ServiceDefaults project:
dotnet new aspire-servicedefaults -n YourApp.ServiceDefaultsReference ServiceDefaults in your API project:
<ProjectReference Include="..\ServiceDefaults\YourApp.ServiceDefaults.csproj" />Add ServiceDefaults to your Program.cs:
var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); // Add this line // ... your existing code ... var app = builder.Build(); app.MapDefaultEndpoints(); // Add health check endpoints app.Run();Configure your AppHost to orchestrate services (see Deep Dive section below)
For detailed instructions, see Microsoft’s official guide: Add .NET Aspire to an existing .NET app
The Problem with Traditional Microservices Development#
The same friction shows up on every project. Here’s where the time goes:
Common Challenges Include:#
| Challenge | Impact |
|---|---|
| Service Discovery | Manually managing URLs and ports across environments |
| Connection Strings | Duplicating database configs in multiple places |
| Observability | Setting up separate tools for logs, traces, and metrics |
| Container Management | Writing complex docker-compose files |
| Health Monitoring | Implementing custom health check endpoints |
| Environment Variables | Managing different configs for dev/staging/prod |
What is .NET Aspire?#
.NET Aspire is a stack for building observable, distributed applications. At its core is the AppHost: a code-first orchestrator that defines your application’s services, resources, and connections.
Key Capabilities#
- AppHost Orchestration: Define services, dependencies, and configuration in code - no YAML required
- Rich Integrations: NuGet packages for popular services with standardized interfaces
- Built-in Observability: OpenTelemetry integration out of the box
- Developer Dashboard: Real-time visibility into all your services
- Consistent Tooling: Works with Visual Studio, VS Code, and CLI
Architecture Overview: This Project with Aspire#
Here’s how this Clean Architecture project is orchestrated with .NET Aspire:
(npm serve)"] API[".NET API
Clean Architecture
(contact-api)"] DB["PostgreSQL
Database
(contactsdb)"] PgAdmin["pgAdmin
DB Management"] end end User -->|"HTTP Requests"| Frontend Frontend -->|"API Calls
(Service Discovery)"| API API -->|"Data Access
(Auto Connection String)"| DB PgAdmin -->|"Management"| DB Services -.->|"Telemetry Data"| Dashboard style Aspire fill:#e6f3ff,stroke:#0066cc style Dashboard fill:#fff2cc,stroke:#cc9900 style Services fill:#e6ffe6,stroke:#009900
Service Flow Explained#
Deep Dive: The AppHost Configuration#
The heart of Aspire is the AppHost.cs file. Here’s our project’s configuration:
var builder = DistributedApplication.CreateBuilder(args);
// Database initialization scripts
var scriptsPath = Path.Combine(builder.AppHostDirectory, "..", "..", "scripts");
// PostgreSQL with pgAdmin and data persistence
var postgres = builder.AddPostgres("postgres")
.WithDataVolume("postgres-data")
.WithPgAdmin()
.WithBindMount(scriptsPath, "/docker-entrypoint-initdb.d");
var contactsDb = postgres.AddDatabase("contactsdb", "contacts");
// Backend API - automatic database reference
var api = builder.AddProject<Projects.Contact_Api>("contact-api")
.WithReference(contactsDb)
.WaitFor(contactsDb)
.WithEnvironment(context =>
{
context.EnvironmentVariables["AppSettings__ConnectionStrings__DefaultConnection"] =
contactsDb.Resource.ConnectionStringExpression;
});
// Angular Frontend - automatic API reference
var frontend = builder.AddNpmApp("frontend", "../../frontend", "serve")
.WithReference(api)
.WaitFor(api)
.WithHttpEndpoint(targetPort: 4200, env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();
builder.Build().Run();What This Code Achieves#
That handful of lines replaces a pile of manual wiring:
| Feature | Traditional Approach | With Aspire |
|---|---|---|
| Database Connection | Manual connection string in appsettings.json | WithReference(contactsDb) - automatic injection |
| Service URLs | Hardcoded http://localhost:5217 | service://contact-api - automatic discovery |
| Container Startup | Manual docker-compose up | WaitFor() - automatic dependency ordering |
| Health Checks | Custom implementation | Built-in with /health and /alive endpoints |
Service Discovery: How It Works#
One of Aspire’s most useful features is automatic service discovery. No more hardcoded URLs.
How Service Discovery Works#
When you call WithReference(api) in the AppHost, Aspire:
- Registers the service in its internal service registry
- Generates environment variables with service endpoints
- Configures HttpClient to resolve service names automatically
- Handles port changes transparently
// In your Angular proxy or API calls, instead of:
// "http://localhost:5217/api/contacts"
// You use:
// "service://contact-api/api/contacts"
// Aspire resolves this at runtime!OpenTelemetry: Built-in Observability#
Aspire ships OpenTelemetry support ready to go through the ServiceDefaults project:
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
// Structured Logging
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
// Metrics and Tracing
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
});
return builder;
}That one ConfigureOpenTelemetry call gives you structured logs, distributed traces, and runtime metrics across every service, with no per-service setup.
The Aspire Dashboard#
When you run your Aspire application, the dashboard launches automatically. It gives you a resources view (service status, endpoints, environment variables, start/stop controls), live console logs filtered by service, a traces view with cross-service waterfalls, and a metrics view.
Dashboard Access#
After running dotnet run --project aspire/AppHost, the dashboard is available at:
| Environment | URL |
|---|---|
| Local Development | https://localhost:17178 |

Why Use Aspire for Microservices#
The gain lands in three places: developer experience (single-command startup, hot reload, easier debugging), operations (built-in monitoring, health checks, dependency ordering), and portability (the same topology runs locally and in the cloud).
Comparison: Before and After Aspire#
| Aspect | Without Aspire | With Aspire |
|---|---|---|
| Setup Time | Hours/Days | Minutes |
| Configuration Files | Multiple (docker-compose, .env, etc.) | Single AppHost.cs |
| Service Discovery | Manual DNS/Environment vars | Automatic |
| Observability | Separate tool integration | Built-in |
| Health Checks | Custom implementation | Automatic |
| Local Development | Complex multi-terminal setup | Single F5/dotnet run |
| Debugging | Difficult across services | Native IDE support |
Getting Started with .NET Aspire#
Prerequisites#
| Requirement | Version | Download |
|---|---|---|
| .NET SDK | 10.0+ | Download |
| Node.js | 22 LTS | Download |
| Docker Desktop | Latest | Download |

npm run serve, discovered automatically by the .NET API through Aspire service references. Seeded contacts load from PostgreSQL on first run.
Quick Start Commands#
# Clone the repository
git clone https://github.com/nitin27may/clean-architecture-docker-dotnet-angular.git
cd clean-architecture-docker-dotnet-angular
# Install frontend dependencies
cd frontend && npm install && cd ..
# Run with Aspire
dotnet run --project aspire/AppHostCreating a New Aspire Project from Scratch#
# Install Aspire workload
dotnet workload install aspire
# Create new Aspire starter app
dotnet new aspire-starter -n MyAspireApp
# Navigate and run
cd MyAspireApp
dotnet run --project MyAspireApp.AppHostUsing the Aspire CLI#
The Aspire CLI provides additional capabilities:
# Install Aspire CLI
dotnet tool install -g aspire.cli
# Create new project
aspire new starter --name MyApp
# Run project
aspire run
# Add integrations
aspire add postgres
aspire add redis
aspire add rabbitmqAspire Integrations Ecosystem#
Aspire ships ready-to-use integrations for the usual suspects: PostgreSQL, SQL Server, MySQL, MongoDB, and Cosmos DB for data; Redis, Garnet, and Valkey for caching; RabbitMQ, Kafka, Azure Service Bus, and NATS for messaging; Azure Storage and AWS S3 for blobs. Each is a NuGet package with a standard registration.
Adding Integrations#
// PostgreSQL with pgAdmin
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin()
.WithDataVolume();
// Redis for caching
var redis = builder.AddRedis("cache");
// RabbitMQ for messaging
var rabbitmq = builder.AddRabbitMQ("messaging");
// Reference in your API
builder.AddProject<Projects.MyApi>("api")
.WithReference(postgres)
.WithReference(redis)
.WithReference(rabbitmq);From Development to Production#
The same AppHost.cs topology that runs locally maps to production targets: aspire deploy can push it to Azure Container Apps, Kubernetes, AWS ECS, or Docker Compose, keeping the same service graph with production-sized resources.
Deployment Example#
| Component | Local (Aspire) | Azure | AWS |
|---|---|---|---|
| Frontend | npm serve | Azure Container Apps | ECS |
| API | dotnet run | Azure Container Apps | Lambda |
| Database | Docker container | Azure PostgreSQL | RDS |
Best Practices#
1. Project Structure#
├── aspire/
│ ├── AppHost/ # Orchestration
│ │ └── AppHost.cs # Service definitions
│ └── ServiceDefaults/ # Shared configurations
│ └── Extensions.cs # OpenTelemetry, health checks
├── backend/
│ └── Contact.Api/ # Your API project
├── frontend/
│ └── src/ # Angular application
└── scripts/
└── seed-data.sql # Database initialization2. Always Use ServiceDefaults#
// In your API's Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); // Add this line!
var app = builder.Build();
app.MapDefaultEndpoints(); // Health check endpoints
app.Run();3. Use WaitFor for Dependencies#
var api = builder.AddProject<Projects.Api>("api")
.WithReference(database)
.WaitFor(database) // Ensures DB is ready
.WithReference(cache)
.WaitFor(cache); // Ensures cache is readyTroubleshooting Common Issues#
| Issue | Solution |
|---|---|
| Port already in use | Stop other services or change port in AppHost |
| Docker not running | Start Docker Desktop first |
| Node modules missing | Run npm install in frontend folder |
| Database not initializing | Check scripts folder path in WithBindMount |
| Services not discovering | Check that WithReference is configured |
The bottom line#
- Aspire is additive. You don’t restructure application code; you add an
AppHostand aServiceDefaultsproject and reference them. WithReferenceplusWaitForreplaces hand-managed connection strings and startup ordering. That pair does most of the work.- Service discovery means
service://contact-apiinstead ofhttp://localhost:5217, so the same code runs locally and in production. - The dashboard’s trace view is the payoff you feel first: cross-service request timelines with no extra tooling.
- On a .NET plus Angular plus PostgreSQL stack, the migration is an afternoon, and the observability alone earns it back.
Try it on your own stack#
If you’re running .NET plus Angular plus PostgreSQL, clone the repo and run dotnet run --project aspire/AppHost. You get the frontend, API, database, and pgAdmin under one command, with service discovery and OpenTelemetry already wired. It’s a low-risk change: the application code doesn’t move, and you can back it out by deleting two projects. Give the dashboard’s trace view ten minutes and you’ll see why it stays.
Resources#
Official Documentation#
- .NET Aspire Overview
- Build Your First Aspire App
- Add Aspire to Existing App
- Aspire Dashboard Documentation
This Project#
Community#
Next Steps#
Related articles:
- Clean Architecture: Introduction to the Project Structure
- Dockerizing the .NET Core API, Angular and MS SQL Server
- Building Production-Ready Microservices with .NET Aspire
