Learnixo

.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 place

AppHost Project

C#
// 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

C#
// 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

C#
// 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:5341

Named Endpoints

C#
// 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 metrics

Production issue I've seen: A team of 6 developers was building a system with 5 microservices. Each developer had their own appsettings.Development.json with 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 WithReference to 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.