Skip to main content

Adding .NET Aspire to an Existing Clean Architecture Project

Adding .NET Aspire to an Existing Clean Architecture Project
Table of Contents

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.cs that starts the Angular frontend, .NET API, PostgreSQL, and pgAdmin with a single command.
  • Automatic service discovery, so no hardcoded localhost:5217 URLs 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.csproj

Steps to Add Aspire to Your Existing Project
#

  1. Install the Aspire workload:

    dotnet workload install aspire
  2. Create the AppHost project:

    dotnet new aspire-apphost -n YourApp.AppHost
  3. Create the ServiceDefaults project:

    dotnet new aspire-servicedefaults -n YourApp.ServiceDefaults
  4. Reference ServiceDefaults in your API project:

    <ProjectReference Include="..\ServiceDefaults\YourApp.ServiceDefaults.csproj" />
  5. 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();
  6. 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:
#

ChallengeImpact
Service DiscoveryManually managing URLs and ports across environments
Connection StringsDuplicating database configs in multiple places
ObservabilitySetting up separate tools for logs, traces, and metrics
Container ManagementWriting complex docker-compose files
Health MonitoringImplementing custom health check endpoints
Environment VariablesManaging 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
#

  1. AppHost Orchestration: Define services, dependencies, and configuration in code - no YAML required
  2. Rich Integrations: NuGet packages for popular services with standardized interfaces
  3. Built-in Observability: OpenTelemetry integration out of the box
  4. Developer Dashboard: Real-time visibility into all your services
  5. 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:

flowchart TB subgraph Browser["Browser"] User[User] end subgraph Aspire[".NET Aspire Orchestration"] subgraph Dashboard["Aspire Dashboard"] Logs[Logs] Traces[Traces] Metrics[Metrics] Resources[Resources] end subgraph Services["Orchestrated Services"] Frontend["Angular Frontend
(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
#

sequenceDiagram participant U as User participant F as Angular Frontend participant A as .NET API participant D as PostgreSQL participant T as Aspire Dashboard Note over U,T: All services automatically discovered via Aspire U->>F: 1. Navigate to app F->>A: 2. API Request (service://contact-api) Note over F,A: Service Discovery - No hardcoded URLs! A->>D: 3. Query Database Note over A,D: Connection string injected by Aspire D-->>A: 4. Return Data A-->>F: 5. JSON Response F-->>U: 6. Render UI Note over A,T: OpenTelemetry sends traces/metrics A--)T: Trace Data F--)T: Trace Data D--)T: Health Status

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:

FeatureTraditional ApproachWith Aspire
Database ConnectionManual connection string in appsettings.jsonWithReference(contactsDb) - automatic injection
Service URLsHardcoded http://localhost:5217service://contact-api - automatic discovery
Container StartupManual docker-compose upWaitFor() - automatic dependency ordering
Health ChecksCustom implementationBuilt-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:

  1. Registers the service in its internal service registry
  2. Generates environment variables with service endpoints
  3. Configures HttpClient to resolve service names automatically
  4. 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:

EnvironmentURL
Local Developmenthttps://localhost:17178

Aspire Dashboard Resources tab showing all 6 resources running: pgadmin, postgres with contactsdb, Angular frontend, postgres-password parameter, and contact-api, all green
Aspire Dashboard, Resources tab for the Clean Architecture project. One command starts everything: pgAdmin, PostgreSQL with the contactsdb database, the Angular frontend (npm run serve), and the .NET API, all with live endpoints and health status.


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
#

AspectWithout AspireWith Aspire
Setup TimeHours/DaysMinutes
Configuration FilesMultiple (docker-compose, .env, etc.)Single AppHost.cs
Service DiscoveryManual DNS/Environment varsAutomatic
ObservabilitySeparate tool integrationBuilt-in
Health ChecksCustom implementationAutomatic
Local DevelopmentComplex multi-terminal setupSingle F5/dotnet run
DebuggingDifficult across servicesNative IDE support

Getting Started with .NET Aspire
#

Prerequisites
#

RequirementVersionDownload
.NET SDK10.0+Download
Node.js22 LTSDownload
Docker DesktopLatestDownload

Angular Contact Portal dashboard showing Dashboard, Contacts and Admin navigation, with seeded contacts and technology cards for PostgreSQL, .NET 10, Angular 21, and .NET Aspire
The Angular frontend, running via 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/AppHost

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

Using 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 rabbitmq

Aspire 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
#

ComponentLocal (Aspire)AzureAWS
Frontendnpm serveAzure Container AppsECS
APIdotnet runAzure Container AppsLambda
DatabaseDocker containerAzure PostgreSQLRDS

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 initialization

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

Troubleshooting Common Issues
#

IssueSolution
Port already in useStop other services or change port in AppHost
Docker not runningStart Docker Desktop first
Node modules missingRun npm install in frontend folder
Database not initializingCheck scripts folder path in WithBindMount
Services not discoveringCheck that WithReference is configured

The bottom line
#

  • Aspire is additive. You don’t restructure application code; you add an AppHost and a ServiceDefaults project and reference them.
  • WithReference plus WaitFor replaces hand-managed connection strings and startup ordering. That pair does most of the work.
  • Service discovery means service://contact-api instead of http://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
#

This Project
#

Community
#


Next Steps
#

Related articles:


Related

Handling Authorization and Role-Based Access Control (RBAC)

··19 mins
Introduction # Static role checks ([Authorize(Roles = "Admin")]) fall apart the first time someone asks you to add a permission without a redeploy. Once roles and permissions have to change at runtime, hard-coded role attributes become a liability. The Contact Management Application takes a different route: a dynamic policy provider that builds authorization policies from the database at request time, covering both the backend API and the Angular frontend, wired into JWT authentication without breaking the separation of concerns Clean Architecture expects.

Using Dapper for Data Access and Repository Pattern

··9 mins
Introduction # On the Contact Management Application I reached for Dapper instead of Entity Framework Core, and the reason was boring: I wanted to see the SQL. EF Core is fine until a generated query does something surprising under load, and then you are reverse-engineering LINQ translations at 2 a.m.