A statistics worker passes every unit test, and its gRPC endpoint works when called directly. The failure appears only after the complete system starts: the message producer launches before the worker is ready, the worker points to a developer-specific database address, and an empty SQL Server instance has no schema.
These are integration failures, not isolated code defects. Each project works alone, but the relationships among projects have not been modeled as part of the application.
.NET Aspire provides a practical way to declare those relationships for local development. An App Host can start SQL Server, inject the database connection, resolve a gRPC service by name, wait for dependencies to become healthy, and collect logs from the complete workflow. The same topology then provides a checklist for creating Docker images and Kubernetes objects.
Context and Scope
Assume a travel platform records purchase events and maintains a daily revenue total.
The solution contains:
TripMetrics.Worker, an ASP.NET Core gRPC service that receives purchase messagesSyntheticPurchaseSource, a background process that generates test purchases- SQL Server, which stores processed message identifiers and daily totals
- A .NET Aspire App Host, which coordinates the local environment
- A Service Defaults project, which adds discovery, resilience, telemetry, and health endpoints
The processing flow is:
SyntheticPurchaseSource
|
| gRPC purchase message
v
TripMetrics.Worker
|
+--> reject duplicate message IDs
|
+--> update daily total
|
v
SQL Server
The source process and worker must not depend on fixed ports or machine-specific addresses. The worker must not start processing against a database whose schema has not been created. The producer must not begin before the worker is reachable.
The target local topology is:
.NET Aspire App Host
|
+--> SQL Server container
| |
| +--> TripMetrics database
|
+--> TripMetrics.Worker
| |
| +--> database reference
| +--> health endpoints
|
+--> SyntheticPurchaseSource
|
+--> worker service reference
Why Running Projects Separately Misses the Real Failure
A developer can start the worker with one connection string, launch SQL Server manually, and call the gRPC endpoint through a hardcoded URL. That proves that a carefully prepared machine can run the solution.
It does not prove that the distributed application can create a repeatable environment.
Manual startup hides several assumptions:
- SQL Server is already installed and reachable.
- The expected database exists.
- Entity Framework Core migrations have been applied.
- The gRPC service uses the same port the producer expects.
- The worker is ready before messages arrive.
- Every developer has matching configuration.
- Docker and Kubernetes will use the same service names.
These assumptions eventually become deployment failures.
The App Host makes them explicit. Instead of documenting a startup sequence in a README and hoping every environment follows it, the application declares resources and dependencies in code.
Step 1: Add the Aspire Projects
Add two projects to the solution:
- An App Host project that describes the distributed application
- A Service Defaults project that contains shared service configuration
Reference the worker and synthetic producer from the App Host. Reference Service Defaults from both executable projects.
A practical solution structure is:
TripMetrics.sln
|
+--> TripMetrics.AppHost
+--> TripMetrics.ServiceDefaults
+--> TripMetrics.Worker
+--> SyntheticPurchaseSource
+--> TripMetrics.Application
+--> TripMetrics.Domain
+--> TripMetrics.Database
Set the App Host as the startup project. Developers should start the distributed application through one entry point rather than launching resources in an undocumented order.
Step 2: Add Service Defaults to the gRPC Worker
The gRPC worker is an ASP.NET Core project, so it can use the shared Aspire defaults directly.
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddGrpc();
builder.Services.AddTripMetricsApplication();
builder.Services.AddTripMetricsDatabase(
builder.Configuration);
var app = builder.Build();
app.MapGrpcService<PurchaseCounterService>();
app.MapDefaultEndpoints();
await app.Services.ApplyDatabaseAsync();
app.Run();
AddServiceDefaults() configures common distributed-application behavior. The generated Service Defaults project normally includes:
- Service discovery
- Standard resilience for HTTP clients
- OpenTelemetry
- Health checks
MapDefaultEndpoints() maps the default health endpoints for the web project.
Health is important because the App Host can use it when deciding whether dependent resources should start. It is also useful later when Kubernetes readiness and liveness probes are configured.
Step 3: Configure the Non-Web Producer Explicitly
The synthetic source is a background process rather than an ASP.NET Core web project. It still needs service discovery and resilient HTTP behavior for its gRPC channel.
Register them through the service collection:
services.AddServiceDiscovery();
services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
services.AddHttpClient<PurchaseGenerator>();
services.AddHostedService<PurchaseGenerator>();
The producer should receive an HttpClient through dependency injection rather than creating a channel around a fixed address and unmanaged handler.
public sealed class PurchaseGenerator : BackgroundService
{
private readonly HttpClient _httpClient;
private readonly ILogger<PurchaseGenerator> _logger;
public PurchaseGenerator(
HttpClient httpClient,
ILogger<PurchaseGenerator> logger)
{
_httpClient = httpClient;
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
using var channel = GrpcChannel.ForAddress(
"http://tripmetrics-worker",
new GrpcChannelOptions
{
HttpClient = _httpClient
});
var client = new PurchaseCounter.PurchaseCounterClient(
channel);
while (!stoppingToken.IsCancellationRequested)
{
var request = CreatePurchaseRequest();
await client.CountAsync(
request,
cancellationToken: stoppingToken);
await Task.Delay(
TimeSpan.FromSeconds(2),
stoppingToken);
}
}
}
The URI contains a logical service name, not localhost and not a development port. Aspire resolves tripmetrics-worker to the endpoint created for that project.
Passing the injected HttpClient into GrpcChannelOptions lets gRPC participate in the same discovery and resilience configuration as other HTTP-based calls.
Step 4: Apply Migrations When the Test Database Starts Empty
The database created by Aspire may start without any application tables. The worker should prepare the schema before processing messages.
Add an extension method in the database driver:
public static class DatabaseStartupExtensions
{
public static async Task<IServiceProvider>
ApplyDatabaseAsync(
this IServiceProvider services)
{
using var scope = services.CreateScope();
var unitOfWork = scope.ServiceProvider
.GetRequiredService<IUnitOfWork>();
if (unitOfWork is TripMetricsDbContext dbContext)
{
await dbContext.Database.MigrateAsync();
}
return services;
}
}
Call it before app.Run().
This approach is useful for development, integration tests, and staging environments where the application is allowed to create or update its schema.
Production database changes may require a separate controlled process and credentials. The important architectural point is that a fresh integration environment must not depend on someone remembering to prepare the schema manually.
Step 5: Declare SQL Server in the App Host
Create the SQL Server resource and the logical database in the App Host.
var builder =
DistributedApplication.CreateBuilder(args);
var sqlServer = builder
.AddSqlServer("metrics-sql")
.WithDataVolume();
var metricsDatabase = sqlServer
.AddDatabase("MetricsConnection");
WithDataVolume() preserves the SQL Server data between local application runs. Remove it when every run should start from an empty database.
The logical database name becomes the connection string name expected by the .NET application. Aspire injects it through the normal configuration system using an environment variable shaped like:
ConnectionStrings__MetricsConnection
The worker should therefore request MetricsConnection from configuration rather than store a development connection string in appsettings.json.
var connectionString =
builder.Configuration.GetConnectionString(
"MetricsConnection")
?? throw new InvalidOperationException(
"The metrics database connection is missing.");
This keeps the application code independent of the SQL Server container address and dynamically assigned port.
Step 6: Declare Dependencies and Startup Order
Add the worker and connect it to the database resource.
var worker = builder
.AddProject<Projects.TripMetrics_Worker>(
"tripmetrics-worker")
.WithReference(metricsDatabase)
.WaitFor(metricsDatabase);
WithReference(metricsDatabase) injects the resource configuration required by the worker.
WaitFor(metricsDatabase) prevents the worker from starting before the SQL Server resource is ready.
Next, add the synthetic source and connect it to the worker:
builder
.AddProject<Projects.SyntheticPurchaseSource>(
"purchase-source")
.WithReference(worker)
.WaitFor(worker);
builder.Build().Run();
This creates an explicit dependency chain:
SQL Server healthy
|
v
TripMetrics.Worker starts
|
v
Migrations are applied
|
v
Worker becomes healthy
|
v
SyntheticPurchaseSource starts
Without this order, the producer may send messages during worker startup, while the worker may attempt database operations before SQL Server is ready.
Startup ordering does not replace retry and resilience. A dependency can still fail after startup. It removes predictable initialization races while resilience handles temporary runtime problems.
Step 7: Remove Machine-Specific Configuration
After the App Host supplies the database reference and service discovery information, remove local values that compete with the injected configuration.
Common examples include:
- A SQL Server connection string in the worker's
appsettings.json - A fixed gRPC URL in the synthetic producer
- A manually selected development port
- A script that requires developers to start SQL Server first
The application should consume logical names:
Database: MetricsConnection
Service: tripmetrics-worker
The environment decides how those names map to addresses and credentials.
This is the same principle that will later allow Kubernetes DNS to replace Aspire's local service discovery without changing the client code.
Step 8: Verify the Complete Workflow in the Aspire Dashboard
Start the App Host while Docker Desktop is running.
The expected startup sequence is visible in the Aspire dashboard:
- SQL Server begins creating its container.
- The worker waits for SQL Server.
- The database becomes healthy.
- The worker starts and applies migrations.
- The worker health endpoint becomes available.
- The synthetic source starts.
- Purchase events appear in the worker logs.
- Daily totals appear in the database.
Do not stop at seeing every resource in a running state. Verify the business effect.
A useful integration check is:
Generated purchase message
|
v
gRPC request accepted
|
v
Duplicate ID check
|
v
Daily total updated
|
v
Transaction committed
The worker's command handler should reject repeated message identifiers and update the daily total inside one transaction. That makes replayed messages safe and prevents two concurrent updates from corrupting the aggregate result.
Step 9: Add an Automated Integration Test Around the App Host
The App Host is valuable for interactive development, but the same topology should also support repeatable integration testing.
A practical test should verify observable behavior:
- The worker becomes healthy.
- The producer can resolve the worker by its logical name.
- A purchase message is accepted.
- The database schema is created.
- One purchase produces one stored record.
- A repeated message ID does not increase the daily total twice.
- A temporary startup delay does not cause the workflow to fail permanently.
Keep the assertion at the application boundary. Avoid testing Aspire's internal implementation. The test should prove that the declared topology can run the business workflow.
Step 10: Use the Aspire Topology as the Kubernetes Contract
A successful Aspire run does not automatically prove the Kubernetes deployment. It gives the team a precise list of objects and configuration that Kubernetes must reproduce.
| Aspire declaration | Kubernetes equivalent |
|---|---|
AddProject("tripmetrics-worker") |
Worker Deployment |
AddProject("purchase-source") |
Producer Deployment |
AddSqlServer() |
SQL Server workload or external database |
WithReference(metricsDatabase) |
Connection string environment configuration |
WithReference(worker) |
Kubernetes Service and DNS name |
WaitFor() |
Startup, readiness, and retry behavior |
MapDefaultEndpoints() |
Readiness and liveness probes |
WithDataVolume() |
Persistent storage |
The logical worker name must remain stable. If the client calls:
http://tripmetrics-worker
the Kubernetes Service should also be named tripmetrics-worker.
A simplified Service is:
apiVersion: v1
kind: Service
metadata:
name: tripmetrics-worker
spec:
selector:
app: tripmetrics
role: worker
ports:
- name: http
port: 80
targetPort: 8080
The Service listens on port 80 because the logical HTTP URI does not specify another port. It forwards traffic to the worker container's port 8080.
Step 11: Build and Load the Development Images
Add container support to both executable projects and build their images.
For a Docker Desktop Kubernetes cluster backed by Kind, inspect the cluster name:
kind get clusters
Load the locally built images into the cluster nodes:
kind load docker-image purchase-source:latest --name desktop
kind load docker-image tripmetrics-worker:latest --name desktop
When images are loaded manually instead of pulled from a registry, configure the Kubernetes workloads with:
imagePullPolicy: Never
This prevents Kubernetes from attempting to download an image that exists only inside the local cluster nodes.
For a shared or production cluster, publish versioned images to an approved container registry instead of using latest and manual loading.
Step 12: Deploy and Verify the Kubernetes Workflow
Create Deployments for the worker and producer, plus the Service for the worker.
The worker Deployment must provide the configuration that Aspire supplied locally:
apiVersion: apps/v1
kind: Deployment
metadata:
name: tripmetrics-worker
spec:
replicas: 1
selector:
matchLabels:
app: tripmetrics
role: worker
template:
metadata:
labels:
app: tripmetrics
role: worker
spec:
containers:
- name: tripmetrics-worker
image: tripmetrics-worker:latest
imagePullPolicy: Never
env:
- name: ASPNETCORE_HTTP_PORTS
value: "8080"
- name: ConnectionStrings__MetricsConnection
valueFrom:
secretKeyRef:
name: tripmetrics-database
key: connection-string
ports:
- name: http
containerPort: 8080
Apply the manifest:
kubectl apply -f tripmetrics-kubernetes.yaml
kubectl get all
Wait until both Pods are ready, then verify that the database receives new purchase and daily-total records.
Remove the test deployment when finished:
kubectl delete -f tripmetrics-kubernetes.yaml
What Aspire Does Not Remove
.NET Aspire reduces local orchestration work, but it does not remove distributed-system responsibilities.
The application still needs:
- Idempotent message processing
- Transaction boundaries
- Retry-safe operations
- Health definitions that reflect real readiness
- Secure production secrets
- Durable production storage
- Versioned container images
- Kubernetes resource limits and scaling decisions
- Production telemetry and alerting
Aspire makes dependencies visible and repeatable. It does not turn a fragile workflow into a reliable one automatically.
Common Mistakes
Keeping localhost in the gRPC client
localhost refers to the current process environment. Use the logical service name and service discovery.
Creating the gRPC channel with an unmanaged HttpClient
Pass an injected client so discovery and standard resilience policies participate in the call.
Starting every project at once without dependency health
Project start order is not enough when SQL Server needs time to become ready. Use explicit references and WaitFor.
Leaving a local connection string in appsettings
The local value can override or conflict with the resource configuration supplied by Aspire.
Assuming MigrateAsync is appropriate for every production environment
Automatic migration is practical for temporary and controlled environments. Production may require reviewed scripts and restricted credentials.
Checking only that resources are running
A green resource list does not prove that the gRPC request was processed or that the database transaction committed.
Renaming the Kubernetes Service
The client depends on the logical service name. Keep the App Host name, client URI, and Kubernetes Service aligned.
Loading local images but leaving the default pull policy
Kubernetes may try to contact a registry and fail even though the image was loaded into the Kind nodes.
Hardcoding production credentials in a manifest
Inject connection strings through an approved secret mechanism instead of committing them with the Deployment.
Integration Checklist
- [ ] Add an App Host project to the solution
- [ ] Add and reference a Service Defaults project
- [ ] Call
AddServiceDefaults()in the ASP.NET Core worker - [ ] Map the default health endpoints
- [ ] Configure discovery and resilience in the non-web producer
- [ ] Create the gRPC channel with an injected
HttpClient - [ ] Replace fixed addresses with a logical service name
- [ ] Declare SQL Server and the logical database in the App Host
- [ ] Persist local database data only when the scenario requires it
- [ ] Inject the database through
WithReference - [ ] Use
WaitForto remove startup races - [ ] Apply migrations before processing test messages
- [ ] Remove competing local connection strings
- [ ] Verify business data, not only resource status
- [ ] Test duplicate message handling
- [ ] Map Aspire project names to Kubernetes Services
- [ ] Recreate connection configuration and health checks in Kubernetes
- [ ] Load local images into Kind or publish them to a registry
- [ ] Verify the complete workflow after deployment
Conclusion
A distributed .NET application is not integrated merely because each project can run by itself.
.NET Aspire lets the team describe the complete local topology: SQL Server, the gRPC worker, the message producer, their configuration references, and their startup dependencies. Service Defaults add discovery, resilience, telemetry, and health behavior, while the App Host turns machine-specific setup into repeatable application code.
Once that workflow succeeds locally, the same declarations become a deployment contract. Kubernetes must recreate the service name, database configuration, health behavior, containers, and startup expectations. This reduces the gap between a developer's machine and the first real orchestration environment.