.NET Aspire · Lesson 2 of 5
Service Discovery in .NET Aspire — No More Hardcoded URLs
What Service Discovery Solves
Without Aspire service discovery:
appsettings.Development.json in PrescriptionService:
"Services": {
"PatientService": "https://localhost:7001",
"LabService": "https://localhost:7002",
"NotificationService": "https://localhost:7003"
}
Problems:
→ Hardcoded ports — if you change the port, update every service's config
→ Developer A runs PatientService on 7001, Developer B runs it on 7100 — config diverges
→ Adding a new service requires updating all consumers
→ No health awareness — a reference to a stopped service fails silently
With Aspire service discovery:
→ AppHost defines all services and their dependencies
→ Services discover each other by name ("patient-service"), not by port
→ Port is assigned dynamically — never hardcoded
→ Aspire Dashboard shows all service health in one placeAppHost Project
// The AppHost is the orchestration entrypoint for local development
// NuGet (AppHost): Aspire.Hosting.AppHost
var builder = DistributedApplication.CreateBuilder(args);
// Infrastructure resources
var sqlServer = builder.AddSqlServer("sqlserver")
.AddDatabase("clinical-db");
var redis = builder.AddRedis("redis");
var serviceBus = builder.AddAzureServiceBus("servicebus");
// Services — each one becomes a process managed by Aspire
var patientService = builder.AddProject<Projects.PatientService>("patient-service")
.WithReference(sqlServer) // injects ConnectionStrings__clinical-db into env
.WithReference(redis);
var prescriptionService = builder.AddProject<Projects.PrescriptionService>("prescription-service")
.WithReference(sqlServer)
.WithReference(redis)
.WithReference(patientService) // service discovery: can call "patient-service"
.WithReference(serviceBus);
var labService = builder.AddProject<Projects.LabService>("lab-service")
.WithReference(sqlServer)
.WithReference(patientService)
.WithReference(prescriptionService);
builder.Build().Run();Consuming a Service by Name
// In PrescriptionService's Program.cs:
builder.Services.AddHttpClient<IPatientServiceClient, PatientServiceClient>(
client => client.BaseAddress = new Uri("https+http://patient-service"));
// "patient-service" is the name registered in AppHost — NOT a hardcoded port
// Aspire injects the actual endpoint address via environment variables:
// services__patient-service__https__0=https://localhost:XXXX
// The HttpClient resolves this automatically via Aspire's service discovery resolver
// PatientServiceClient:
public sealed class PatientServiceClient : IPatientServiceClient
{
private readonly HttpClient _http;
public PatientServiceClient(HttpClient http) => _http = http;
public async Task<PatientSummary?> GetByIdAsync(Guid patientId, CancellationToken ct)
{
var response = await _http.GetAsync($"/api/patients/{patientId}", ct);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PatientSummary>(ct);
}
}Adding External Services
// Aspire can also represent external/cloud services for local development
// PostgreSQL via Docker
var postgres = builder.AddPostgres("postgres")
.AddDatabase("marten-db");
// Existing Azure resources (not spun up locally — just configured)
var existingServiceBus = builder.AddConnectionString("servicebus");
// Kafka (requires Docker)
var kafka = builder.AddKafka("kafka");
// Custom container (e.g., Seq for structured logging)
var seq = builder.AddContainer("seq", "datalust/seq")
.WithEndpoint(port: 5341, scheme: "http", name: "ingest")
.WithEndpoint(port: 8080, scheme: "http", name: "ui");
// Reference Seq from services for Serilog:
var patientService = builder.AddProject<Projects.PatientService>("patient-service")
.WithReference(seq);
// Aspire injects: services__seq__ingest__0=http://localhost:5341Named Endpoints
// A service can expose multiple endpoints (e.g., HTTP for API, gRPC for internal)
var prescriptionService = builder.AddProject<Projects.PrescriptionService>("prescription-service")
.WithEndpoint(name: "http", port: 5100, scheme: "http")
.WithEndpoint(name: "grpc", port: 5101, scheme: "https");
// Consumer references specific endpoint:
var pharmacyService = builder.AddProject<Projects.PharmacyService>("pharmacy-service")
.WithReference(prescriptionService, endpointName: "grpc");
// In PharmacyService — resolved from service discovery:
builder.Services.AddGrpcClient<PrescriptionGrpc.PrescriptionGrpcClient>(client =>
client.Address = new Uri("https+http://prescription-service/grpc"));Aspire Dashboard
The Aspire Dashboard launches automatically with the AppHost.
→ URL: http://localhost:15888
Dashboard shows:
Resources tab:
→ All services and their status (Running, Stopped, Unhealthy)
→ Port assignments (dynamically assigned by Aspire)
→ Environment variables injected into each service
Console Logs tab:
→ Structured log output from all services in one view
→ Filter by service, severity, or search term
Structured Logs tab:
→ OpenTelemetry structured logs from all services
→ Full JSON including correlation IDs, trace IDs
Traces tab:
→ Distributed traces across all services
→ End-to-end request flow: API → PrescriptionService → PatientService → Database
Metrics tab:
→ Prometheus metrics from all services
→ HTTP request rates, error rates, custom business metricsProduction issue I've seen: A team of 6 developers was building a system with 5 microservices. Each developer had their own
appsettings.Development.jsonwith port assignments. Port conflicts were resolved by each developer choosing different port ranges. Over 3 months, the team accumulated 4 different port configuration conventions, 3 broken developer environments (wrong ports after a merge), and a shared "ports spreadsheet" to track who was using what. Introducing Aspire took 2 hours — dynamic port assignment eliminated all port conflicts, and the Aspire Dashboard replaced 5 separate terminal windows showing interleaved logs.
Key Takeaway
Aspire's AppHost is the single source of truth for local development topology. Services discover each other by name (not port) — ports are dynamically assigned. Use
WithReferenceto declare service dependencies — Aspire injects endpoint addresses as environment variables. The Aspire Dashboard provides a unified view of logs, traces, metrics, and resource health for all services. Aspire eliminates the "works on my machine, but not yours" problem caused by hardcoded port configurations.