Service Discovery
Resolve logical service names like https+http://catalog to real endpoints from configuration, Aspire or Kubernetes DNS.
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(h => h.AddServiceDiscovery());
builder.Services.AddHttpClient<CatalogClient>(h => h.BaseAddress = new("https+http://catalog"));Microsoft.Extensions.ServiceDiscovery (10.11.0) lets code address another service by a logical name and resolves the real endpoint at request time. A typed client sets BaseAddress = new Uri("https+http://catalog"); a handler added by AddServiceDiscovery() rewrites each request to a concrete address such as https://10.42.0.17:8443. The same code then works on a laptop, under the Aspire AppHost and in Kubernetes, with only configuration changing.
Wiring it up
Two calls: builder.Services.AddServiceDiscovery() registers the resolver with a configuration-based provider and a pass-through provider, and AddServiceDiscovery() on an HTTP client builder, usually inside ConfigureHttpClientDefaults, adds the resolving handler to clients. Aspire's service defaults project does both.
Name syntax
| URI | Meaning |
|---|---|
http://catalog | The http endpoints of service catalog |
https+http://catalog | Prefer https endpoints; use http only if none exist |
https://_grpc.catalog | The endpoint named grpc of catalog, for a service exposing several ports |
Configuration provider
Endpoints come from the Services section: Services:catalog:https:0 = https://localhost:7101. In environment variables that is services__catalog__https__0, which is exactly what the Aspire AppHost injects into a project for each WithReference. Several entries form a list and the resolver spreads requests across them. Resolved endpoints are cached and refreshed (every 60 seconds by default, ServiceDiscoveryOptions.RefreshPeriod).
In Kubernetes
When no provider has endpoints for a name, the pass-through provider hands the host name to DNS: http://catalog becomes a request to host catalog on port 80, which inside the cluster resolves to the catalog Service in the same namespace. Many teams therefore need no service discovery configuration in the cluster at all, or set services__catalog__https__0=https://catalog.shop.svc.cluster.local:8443 to be explicit. Remember that a ClusterIP Service balances connections, not requests: with keep-alive and HTTP/2, a client stays pinned to the pods it first connected to until connections recycle, which is one more reason to keep handler lifetimes bounded. Headless Services and DNS SRV lookups are available through the separate Microsoft.Extensions.ServiceDiscovery.Dns package.
Security and failure modes
https+httpsilently falls back to plain HTTP when only anhttpendpoint is configured. In production, setAllowAllSchemes = falseandAllowedSchemes = ["https"]inServiceDiscoveryOptions; settingAllowedSchemesalone has no effect whileAllowAllSchemesis true, its default. The restriction applies to every name, not onlyhttps+httpones: with https only,http://inventoryno longer resolves, so serve https in the cluster too. Where pod-to-pod HTTP is deliberate (a mesh doing mTLS), use plainhttp://names and leave schemes unrestricted.- A misspelt or missing
Servicesentry is not an error: it falls through to DNS and fails as "name or service not known" for hostcatalog, which looks like a network problem. - Service discovery is not load balancing or health checking. A configured endpoint that is down stays in the list; resilience handlers and the platform's readiness probes cover that.
Example
using Microsoft.Extensions.ServiceDiscovery;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http => http.AddServiceDiscovery());
if (!builder.Environment.IsDevelopment())
{
// Never let https+http fall back to plain HTTP outside a developer machine.
builder.Services.Configure<ServiceDiscoveryOptions>(options =>
{
options.AllowAllSchemes = false;
options.AllowedSchemes = ["https"];
});
}
builder.Services.AddHttpClient<CatalogClient>(http =>
{
http.BaseAddress = new Uri("https+http://catalog");
http.Timeout = TimeSpan.FromSeconds(5);
});
var app = builder.Build();
app.MapGet("/orders/{orderId:guid}/lines", async (Guid orderId, CatalogClient catalog, CancellationToken ct) =>
{
string[] skus = ["KETTLE-BLUE-1L", "FILTER-PACK-6"];
var products = await catalog.GetProductsAsync(skus, ct);
return Results.Ok(new { orderId, products });
});
app.Run();
public sealed class CatalogClient(HttpClient http)
{
public async Task<Product[]> GetProductsAsync(IEnumerable<string> skus, CancellationToken ct)
{
var query = string.Join("&", skus.Select(s => $"sku={Uri.EscapeDataString(s)}"));
return await http.GetFromJsonAsync<Product[]>($"/api/products?{query}", ct) ?? [];
}
}
public sealed record Product(string Sku, string Name, decimal Price);
When to use it
- Run the orders API against a local catalog on localhost:7101 and against catalog.shop.svc.cluster.local in Kubernetes without changing code.
- Let the Aspire AppHost inject the inventory service's address into the orders API through WithReference.
- Refuse to call the payment service over plain HTTP in production even though the client uses https+http://payments.
More examples
Endpoints for local development
Each scheme key holds a list of endpoints; with two inventory instances the resolver alternates between them. A key that is not a scheme, like <code>grpc</code>, is a named endpoint and is addressed as <code>http://_grpc.inventory</code>. When the Aspire AppHost runs the app, these values come from injected environment variables instead and this file is not needed.
{
// appsettings.Development.json: the catalog runs on the developer's machine.
"Services": {
"catalog": {
"https": [ "https://localhost:7101" ],
"http": [ "http://localhost:5101" ]
},
"inventory": {
"http": [ "http://localhost:5201", "http://localhost:5202" ],
"grpc": [ "http://localhost:5299" ]
}
}
}Explicit endpoints in a Kubernetes Deployment
Double underscores map to the <code>Services:catalog:https:0</code> keys, and configuration keys are case-insensitive, so the lower-case <code>services</code> prefix the AppHost uses works too. Fully qualified Service names make the target namespace explicit, which avoids a surprise when the same image is deployed into another namespace that also has a Service called <code>catalog</code>. Both endpoints are https because the production resolver allows only that scheme for every name; an <code>http</code> entry here would fail to resolve.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: shop
spec:
replicas: 3
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: api
image: registry.example.com/shop/orders-api:2.3.0
ports:
- containerPort: 8080
env:
- name: services__catalog__https__0
value: https://catalog.shop.svc.cluster.local:8443
- name: services__inventory__https__0
value: https://inventory.shop.svc.cluster.local:8443Logging what each name resolves to at start-up
<code>ServiceEndpointResolver</code> is the service the HTTP handler uses, so this shows exactly where requests will go. A name that prints as <code>Unspecified/catalog:80</code> came from the pass-through provider, meaning no configuration matched; that one log line turns a confusing DNS error in production into an obvious configuration mistake.
using Microsoft.Extensions.ServiceDiscovery;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http => http.AddServiceDiscovery());
var app = builder.Build();
var resolver = app.Services.GetRequiredService<ServiceEndpointResolver>();
string[] dependencies = ["https+http://catalog", "https+http://inventory", "https+http://_grpc.inventory"];
foreach (var name in dependencies)
{
var source = await resolver.GetEndpointsAsync(name, CancellationToken.None);
app.Logger.LogInformation("{Service} resolves to {Endpoints}",
name, string.Join(", ", source.Endpoints.Select(e => e.EndPoint.ToString())));
}
app.Run();
Discussion