Aspire Service Discovery: Names, Ports, Replicas
Learn how Aspire service discovery turns logical names into endpoints with WithReference, HttpClient resolution, health checks, and replica load balancing.
Your gateway calls a catalog at http://localhost:5147. The URL works until another developer has
that port, the AppHost assigns a different one, the service moves into a container, or three catalog
replicas replace one process. The business dependency is still "catalog," but the code knows a fact
about today's topology.
Aspire service discovery separates those two ideas. Application code keeps the logical name;
the AppHost supplies the current endpoints to the processes that reference that resource; an
HttpClient resolver turns the name into an address when the call runs. Understanding those three
parts makes the feature predictable and makes its failures much easier to diagnose.
The AppHost publishes an address to a specific caller
Start with the relationship in AppHost.cs:
var builder = DistributedApplication.CreateBuilder(args);
var catalog = builder.AddProject<Projects.Catalog>("catalog")
.WithHttpEndpoint();
builder.AddProject<Projects.Gateway>("gateway")
.WithReference(catalog);
builder.Build().Run();
AddProject(..., "catalog") gives the resource its logical name. WithReference(catalog) does not
copy that URL into every process in the application. It injects the catalog's discovery information
into the gateway because the gateway declared that dependency. Another service without the
reference does not receive it.
For .NET callers, an HTTP endpoint becomes configuration shaped like this after environment-variable keys are normalized:
services:catalog:http:0 = http://127.0.0.1:62143
The port is an example, not a contract. The relevant contract is the resource name catalog, the
endpoint name or scheme http, and an indexed address. Aspire can write multiple addresses under
the same service when the discovery provider needs them.
The current Aspire service-discovery documentation
describes the same four-stage path: the AppHost declares resources, WithReference injects
configuration, application code uses a logical URI, and the resolver selects the real endpoint.
1. AppHost model
gateway.WithReference(catalog)
Declares who may discover which resource.
2. Configuration
services__catalog__http__0
Carries the current endpoint into the caller.
3. Resolver
https+http://catalog
Turns the logical name into the injected address.
4. Proxy
Keeps one address while replicas change.
WithReferenceis dependency wiring. It is not network authorization, a retry policy, or proof that the target is ready when the first request arrives.
The caller still needs a resolver
The AppHost can hand the gateway a perfect services:catalog:http:0 value while a plain
HttpClient still asks the operating-system DNS resolver for a host literally named catalog.
Discovery works only when the caller's HTTP pipeline knows how to read the injected configuration.
The Aspire Service Defaults template normally registers that plumbing. If an existing service does not use Service Defaults, the explicit registration is small:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
http.AddServiceDiscovery());
builder.Services.AddHttpClient<CatalogClient>(client =>
client.BaseAddress = new Uri("https+http://catalog"));
The typed client holds no port and no environment-specific host. https+http://catalog tells the
resolver to prefer the catalog's HTTPS endpoint and fall back to HTTP. Use https://catalog when
HTTPS is mandatory or http://catalog when the service intentionally exposes only HTTP.
Do not confuse a named client with a named service. AddHttpClient("catalog-client", ...) names a
DI registration. The host portion of the base address, catalog, is the discovery name and must
match the resource name or reference alias supplied by the AppHost.
Named endpoints are part of the logical address
One service can expose more than one endpoint: its public API, an administrative interface, or a metrics endpoint. Aspire addresses a named endpoint with an underscore prefix before the service name:
// AppHost.cs
var catalog = builder.AddProject<Projects.Catalog>("catalog")
.WithHttpEndpoint(name: "api")
.WithHttpEndpoint(port: 9090, name: "admin");
// Gateway/Program.cs
builder.Services.AddHttpClient<CatalogAdminClient>(client =>
client.BaseAddress = new Uri("http://_admin.catalog"));
The first snippet is the AppHost model, and the second is the calling service. The underscore means
admin is an endpoint name, not a subdomain. If resolution fails, compare the endpoint name
character for character and confirm that the caller references the resource that exposes it.
Keep management endpoints out of the default reference set when ordinary callers should not receive them. Discovery configuration is not a security boundary, but distributing fewer addresses reduces accidental coupling and makes the intended topology clearer.
Readiness and discovery solve different failures
Service discovery answers "where is catalog?" It does not answer "is catalog ready?" A process can have an allocated endpoint before migrations finish, caches warm, or required data loads. Starting the gateway as soon as the catalog process exists can turn the first seconds of every deployment into avoidable 503 responses.
Model startup readiness separately:
var catalog = builder.AddProject<Projects.Catalog>("catalog")
.WithHttpEndpoint()
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.Gateway>("gateway")
.WithReference(catalog)
.WaitFor(catalog);
With the health check attached, WaitFor(catalog) holds the gateway until /health reports the
catalog ready. Without a resource health check, waiting for a resource can mean waiting only for its
running state. Aspire's health-check guidance
distinguishes AppHost startup checks from service endpoints used by load balancers and deployment
platforms.
This ordering happens once. If catalog becomes unavailable at 10 a.m., WaitFor does nothing. The
runtime call still needs timeouts, cancellation, retries where safe, and a circuit breaker where a
failing dependency would otherwise consume the caller. Startup orchestration and runtime resilience
are complementary, not interchangeable.
One logical name can sit in front of replicas
A fixed port becomes especially brittle when one catalog process becomes three. Aspire project resources can express that topology directly:
var catalog = builder.AddProject<Projects.Catalog>("catalog")
.WithHttpEndpoint()
.WithHttpHealthCheck("/health")
.WithReplicas(3);
For proxied project endpoints, Aspire starts a proxy in front of the processes and load balances requests across their random internal ports. Consumers still resolve one catalog endpoint. The official project-resource networking documentation explains why the proxy owns the stable endpoint while each replica listens on a different internal port.
That is inner-loop scale for testing application behavior, not a production autoscaler. It is useful for exposing assumptions such as in-memory session affinity, process-local idempotency records, or a cache that silently depended on every request reaching the same instance. Your deployment target still decides how production replicas are created and discovered.
Diagnose discovery from the contract outward
When a call reports "No endpoints resolved" or a connection failure, inspect the path in order:
- Model: does the resource name match the logical URI?
- Reference: did this caller use
WithReferencefor that resource? - Configuration: does the caller contain a
services:<name>:<endpoint>:<index>value? - Resolver: did Service Defaults or
AddServiceDiscoveryadd discovery to this client? - Endpoint: does the scheme or
_namedendpoint match what the resource exposes? - Readiness: is the endpoint resolved but the service still warming up or failing health checks?
This order separates "the name cannot resolve" from "the resolved service refused the call." The first is model or resolver wiring. The second is availability, readiness, networking, or application behavior. Treating every socket error as discovery creates long debugging sessions around the wrong layer.
Katabench's Aspire discovery lab makes each layer observable in a running system. You start with a
gateway that cannot resolve catalog, inspect the exact injected configuration, add readiness
ordering, remove the fixed port, and sample one logical address until three distinct replicas have
answered. The Labs overview lists the Aspire course, while the Labs guide
explains how its temporary workspaces and executable checks work.
The practical rule is simple: keep names in application code, keep addresses in the environment, and prove the resolver is reading the same contract the AppHost wrote. Once that boundary is clear, ports and replica counts can change without becoming source-code changes.