Service Discovery in .NET: From Aspire to Production

In a distributed application, services need to find each other. Hardcoding URLs like http://localhost:5123 breaks the moment you deploy to a different environment. Service discovery solves this by letting services refer to each other by name, with the actual addresses resolved at runtime.

How Service Discovery Works in Aspire

When you write WithReference in the AppHost, Aspire sets up service discovery automatically:

Example.cs
var catalogApi = builder.AddProject<Projects.CatalogApi>("catalog-api");

var frontend = builder.AddProject<Projects.Frontend>("frontend")
    .WithReference(catalogApi);

The frontend can now call http://catalog-api instead of http://localhost:5234. Aspire injects configuration that maps the name catalog-api to the actual host and port.

The ServiceDefaults project enables this on the consuming side:

Program.cs
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddServiceDiscovery();
});

Using Service Discovery with HttpClient

Register a named or typed HttpClient that uses a service name as the base address:

Program.cs
builder.Services.AddHttpClient<CatalogClient>(client =>
{
    client.BaseAddress = new Uri("https+http://catalog-api");
});

The https+http:// scheme tells the service discovery system to try HTTPS first, then fall back to HTTP. Other supported schemes are http:// (HTTP only) and https:// (HTTPS only).

Your typed client uses it naturally:

Example.cs
public class CatalogClient
{
    private readonly HttpClient _httpClient;

    public CatalogClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<List<Product>> GetProductsAsync()
    {
        return await _httpClient.GetFromJsonAsync<List<Product>>("/products")
            ?? [];
    }

    public async Task<Product?> GetProductAsync(int id)
    {
        return await _httpClient.GetFromJsonAsync<Product>($"/products/{id}");
    }
}

Configuration-Based Discovery

Outside of Aspire's AppHost, you can configure service endpoints manually. This is useful when deploying to environments where the AppHost is not running:

data.json
{
  "Services": {
    "catalog-api": {
      "https": [
        "https://catalog.mycompany.com"
      ]
    },
    "order-api": {
      "https": [
        "https://orders.mycompany.com"
      ]
    }
  }
}

The service discovery system reads this configuration and resolves http://catalog-api to https://catalog.mycompany.com. Your application code does not change between environments.

DNS-Based Discovery

In Kubernetes and similar environments, services are discoverable via DNS. The .NET service discovery system supports DNS SRV records out of the box.

Enable DNS discovery:

Example.cs
builder.Services.AddServiceDiscovery()
    .AddDnsSrvServiceEndpointProvider();

When running in Kubernetes, http://catalog-api resolves via the cluster's DNS service. No configuration files needed — Kubernetes handles the name resolution.

Load Balancing

When a service has multiple endpoints — either from configuration or DNS — service discovery can distribute requests across them. Configure the resolution strategy:

Example.cs
builder.Services.Configure<ServiceDiscoveryOptions>(options =>
{
    options.AllowAllSchemes = true;
});

With multiple endpoints configured:

data.json
{
  "Services": {
    "catalog-api": {
      "https": [
        "https://catalog-1.mycompany.com",
        "https://catalog-2.mycompany.com",
        "https://catalog-3.mycompany.com"
      ]
    }
  }
}

The HttpClient will distribute requests across all three endpoints.

Service Discovery with YARP

If you are using YARP (Yet Another Reverse Proxy) as an API gateway, service discovery integrates directly:

Example.cs
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddServiceDiscoveryDestinationResolver();

Your YARP configuration can reference service names:

data.json
{
  "ReverseProxy": {
    "Routes": {
      "catalog-route": {
        "ClusterId": "catalog",
        "Match": { "Path": "/api/catalog/{**catch-all}" }
      }
    },
    "Clusters": {
      "catalog": {
        "Destinations": {
          "destination1": {
            "Address": "https+http://catalog-api"
          }
        }
      }
    }
  }
}

The Resolution Pipeline

Service discovery uses a pipeline of providers that are checked in order:

  1. Configuration — checked first, allows explicit endpoint overrides
  2. DNS — falls back to DNS resolution if configuration does not have the service
  3. Passthrough — if nothing resolves, the original URI is used as-is

This pipeline means you can override specific services via configuration while letting DNS handle the rest.

Environment Parity

The beauty of .NET's service discovery is environment parity. Your code always uses http://catalog-api. In development, Aspire resolves it. In staging, configuration resolves it. In production, DNS resolves it. The application code is identical across all environments.

This is a fundamental principle of cloud-native applications: services should not know or care where their dependencies are running. Service discovery makes that principle practical.