Service Discovery and ServiceDefaults: How "https+http://catalog" Resolves and What the Defaults Do
The address "https+http://catalog" resolves through the Services:catalog configuration read from the services__catalog__http__0 variable Aspire provides; with no entry the name is left to DNS, so the same code works in production. The post shows the resolution logs, that the template's AllowedSchemes comment does nothing unless AllowAllSchemes is turned off, what ServiceDefaults contains, cutting a 30-second wait for a stopped service to 5 seconds, and a YARP gateway, with real output.
Version note: The code in this post was built and run with .NET 10, Aspire 13.6, Microsoft.Extensions.ServiceDiscovery 10.11 and Yarp.ReverseProxy 2.3.
Guess First
The Extensions.cs file of the ServiceDefaults template contains a commented-out example: it says "uncomment this to restrict the allowed schemes for service discovery", and below it is the line options.AllowedSchemes = ["https"];. You uncommented it. Ordering calls Catalog at https+http://catalog, and Catalog has only an HTTP endpoint. What happens to the call: does it fall back to HTTP and keep working, or fail because it can't find HTTPS? We run both variations in the middle of the post.
Ordering Calls Catalog by Name
We add a second service to Bookstore, the online bookstore used as the shared example across this site's series: Bookstore.Ordering.Api. When it takes an order, it reads each line's title and price from Catalog. How to split the services and design the order flow is the topic of the .NET ile Mikroservis Uygulaması series; here Ordering is deliberately simple (orders live in memory) because the focus is how two services find each other. The typed client that goes to Catalog:
namespace Bookstore.Ordering.Api.Orders;
public sealed record CatalogBook(string Isbn, string Title, decimal Price, string Currency);
// Typed client; the base address is a logical name resolved by service discovery
public sealed class CatalogClient(HttpClient http)
{
public async Task<CatalogBook?> GetBookAsync(string isbn, CancellationToken ct)
{
using HttpResponseMessage response = await http.GetAsync($"/api/books/{isbn}", ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<CatalogBook>(ct);
}
}
And its registration:
builder.AddServiceDefaults();
builder.Services.AddHttpClient<CatalogClient>(client => client.BaseAddress = new Uri("https+http://catalog"));
No port, no host in the code. catalog is the resource name in the AppHost. On the AppHost side we give Ordering a reference to Catalog and put a gateway in front:
IResourceBuilder<ProjectResource> ordering = builder.AddProject<Projects.Bookstore_Ordering_Api>("ordering")
.WithReference(catalog)
.WaitFor(catalog)
.WithHttpHealthCheck("/health");
IResourceBuilder<ProjectResource> gateway = builder.AddProject<Projects.Bookstore_Gateway>("gateway")
.WithReference(catalog)
.WithReference(ordering)
.WithHttpHealthCheck("/health");
builder.AddJavaScriptApp("web", "../../web/bookstore-web", "start")
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.WithReference(gateway)
.WaitFor(gateway);
Angular now only knows the gateway; proxy.conf.js reads GATEWAY_HTTPS/GATEWAY_HTTP. An order through the gateway:
curl -i -X POST http://localhost:5070/api/orders -H "Content-Type: application/json" \
-d '{"customerEmail":"ada@example.com","items":[{"isbn":"9780441172719","quantity":1},{"isbn":"9780547928227","quantity":2}]}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/orders/01a1dab9-91ac-707a-9e65-24fd61280668
{"id":"01a1dab9-91ac-707a-9e65-24fd61280668","customerEmail":"ada@example.com","lines":[{"isbn":"9780441172719","title":"Dune","unitPrice":420.00,"quantity":1,"lineTotal":420.00},{"isbn":"9780547928227","title":"The Hobbit","unitPrice":340.00,"quantity":2,"lineTotal":680.00}],"total":1100.00,"currency":"TRY","placedAtUtc":"2026-11-14T15:42:36.972609+00:00"}
How Does "https+http://catalog" Resolve?
The concept of service discovery (client-side and server-side discovery, the registry) is covered in the Service Discovery post of the Microservices series; here we look at its .NET implementation. AddServiceDiscovery() inside AddServiceDefaults() registers the Microsoft.Extensions.ServiceDiscovery library, and ConfigureHttpClientDefaults adds it as a handler to every HttpClient's pipeline. As a request goes out, the handler turns the host in the URI (catalog) into a real address. Where do the addresses come from? From the variables WithReference(catalog) gives Ordering:
services__catalog__http__0 = http://localhost:5080
CATALOG_HTTP = http://localhost:5080
In .NET configuration a double underscore means :, so this variable is the key Services:catalog:http:0. The library's configuration provider reads exactly this path. With debug logging on (Logging__LogLevel__Microsoft.Extensions.ServiceDiscovery=Debug) you can see what happens on the first request:
Creating endpoint resolver for service 'https+http://catalog' with 2 providers: Configuration, Pass-through.
Resolving endpoints for service 'https+http://catalog'.
Using configuration from path 'Services:catalog:http' to resolve endpoint 'http' for service 'catalog'.
Endpoints configured for service 'catalog' from path 'Services:catalog:http': http://localhost:5080/.
Resolved 1 endpoints for service 'https+http://catalog': http://localhost:5080/ (Configuration).
There are two providers and they're tried in order. The configuration provider looks for an address under Services:catalog:<endpoint>. If it finds nothing, the pass-through provider steps in: it leaves the name as is (catalog) as a DNS name. The scheme list https+http means "HTTPS first, HTTP if there's none". Because we ran Catalog with the http launch profile, only Services:catalog:http exists and resolution landed there. With the https profile Aspire would also provide services__catalog__https__0, and the same code would pick HTTPS without any change. The library's rule is clear too: if an HTTPS endpoint is found, HTTP endpoints aren't added to the list at all.
If a service has more than one endpoint, you give the name with an underscore: https+http://_admin.catalog goes to Catalog's endpoint named admin. The underscore says this is an endpoint name, not a subdomain.
The Same Code Without Aspire
To see that this mechanism doesn't depend on Aspire, we ran Ordering outside the AppHost with a variable we set by hand:
services__catalog__http__0=http://localhost:5080 dotnet run --project src/Bookstore.Ordering.Api --no-launch-profile
The order returned 201 again, and the log again said (Configuration). You can also write the same value into appsettings.json; if you give more than one address, one of them is picked on each resolution:
{
"Services": {
"catalog": {
"http": [ "localhost:5080" ]
}
}
}
In production you usually don't even need that. If there's a Service named catalog in Kubernetes or an app named catalog in Azure Container Apps, the platform's DNS already resolves the name; with no entry in configuration, the pass-through provider leaves the name as is and the request goes to the host catalog. There's no "this address locally, that address in production" branch in the code.
The AllowedSchemes Experiment
Back to the question from Guess First. We ran Ordering outside the AppHost again and tried both variations. The first is exactly the template's comment:
builder.Services.Configure<ServiceDiscoveryOptions>(options =>
{
options.AllowedSchemes = ["https"];
});
Resolved 1 endpoints for service 'https+http://catalog': http://localhost:5080/ (Configuration).
Nothing changed; the call went over HTTP and the order returned 201. The reason is the second property on ServiceDiscoveryOptions: AllowAllSchemes defaults to true, and as long as it's true the AllowedSchemes list isn't consulted. In the second variation we turned it off too:
builder.Services.Configure<ServiceDiscoveryOptions>(options =>
{
options.AllowAllSchemes = false;
options.AllowedSchemes = ["https"];
});
Resolved 1 endpoints for service 'https+http://catalog': catalog (Pass-through).
System.Net.Http.HttpRequestException: ... (catalog:443)
This time the configuration provider didn't use the HTTP address; finding no HTTPS, resolution fell through to pass-through, and the request tried to reach catalog:443 and failed with a DNS error. So the restriction really works with two lines. The template's comment on its own is misleading; we fixed the comment in the repo's ServiceDefaults so that whoever uncomments it one day gets the right code. Allowing only HTTPS in production makes sense where internal traffic also goes over TLS; in Kubernetes internal traffic is often plain HTTP, so decide this per environment.
Non-.NET Resources and the Container Network
The services__catalog__http__0 shape is designed for .NET configuration. Because it's awkward to read from Node.js, Python or Go, Aspire also provides the same address as CATALOG_HTTP (and CATALOG_HTTPS if there's an HTTPS endpoint): the resource name and the endpoint name in upper case. Angular's proxy.conf.js has used this since 01; now it reads GATEWAY_HTTP.
The content of the address depends on who uses it. For processes running on the host, Catalog is localhost:5080. Inside a container, though, localhost means the container itself. To see this, we temporarily added Aspire's YARP container to the AppHost (covered below) and looked at its environment variables:
CATALOG_HTTP = http://aspire.dev.internal:5080
services__catalog__http__0 = http://aspire.dev.internal:5080
OTEL_EXPORTER_OTLP_ENDPOINT = http://aspire.dev.internal:19010
Aspire shows the services on the host to containers under the name aspire.dev.internal; that name reaches the host through a tunnel Aspire sets up on the container network. The same logic applies in the other direction: a container's endpoint is given to a process on the host as localhost:<port> and to another container by its name on the container network. In the AppHost API this distinction is carried by NetworkIdentifier (KnownNetworkIdentifiers.LocalhostNetwork, KnownNetworkIdentifiers.DefaultAspireContainerNetwork); with GetEndpoint("http", networkIdentifier) you can get the address of an endpoint as seen from a particular network. WithReference does this for you; you only need to remember the distinction when you build an address by hand.
Inside ServiceDefaults and Our Own Defaults
In 01 we added ServiceDefaults from the template without looking inside. AddServiceDefaults() does four things. ConfigureOpenTelemetry() turns on logs, metrics and traces: ASP.NET Core, HttpClient and runtime instrumentation, an ActivitySource with the app's name, and the OTLP exporter if OTEL_EXPORTER_OTLP_ENDPOINT is set. Requests to /health and /alive don't end up in traces; a filter keeps them out. AddDefaultHealthChecks() adds a "self" check tagged live. AddServiceDiscovery() registers the resolution we saw above. Finally, a standard resilience handler is added to every HttpClient: rate limiter, total timeout, retry, circuit breaker and per-attempt timeout. The details of the pipeline are in the Standard Pipeline post of the External Service Integration ve Resilience series.
MapDefaultEndpoints() maps two endpoints: /health (readiness: all checks must pass; can it take traffic?) and /alive (liveness: only those tagged live; should the process be restarted?). Both are mapped only in the Development environment. You'll want them in production, because Kubernetes probes and load balancers look at them, but the Aspire documentation's warning is to the point: a public health endpoint is a door through which anyone can trigger checks that hit the database and Redis on every request. The recommended measures are host filtering or authorization, a short request timeout and keeping the response in an output cache for a few seconds. Deleting the IsDevelopment() condition without doing this is the most common mistake.
How Long Does Ordering Wait When Catalog Is Down?
Without changing the template's resilience settings, we stopped Catalog with aspire resource catalog stop and placed an order through the gateway:
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" -X POST http://localhost:5070/api/orders ...
500 30.031173s
Thirty seconds. Ordering's log has the reason: every attempt ends with The operation didn't complete within the allowed timeout of '00:00:10', and the total runs into the standard pipeline's 30-second upper limit. Why wasn't the connection refused right away? Because of the DCP proxy we saw in 01: even with Catalog stopped, the proxy listens on 5080, accepts the connection and never answers. A direct curl -m 5 http://localhost:5080/api/books also got no answer (000) after 5 seconds. In production a crashed pod usually refuses connections, but a service slowing down under heavy load produces the same picture: with the default durations the caller waits half a minute while the user has long since closed the page.
That budget is too much for Bookstore's services calling each other. We updated ServiceDefaults with our own defaults:
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default; calls between Bookstore services fail fast
http.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(5);
});
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// To allow only HTTPS, AllowAllSchemes must be turned off as well;
// setting AllowedSchemes alone changes nothing.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowAllSchemes = false;
// options.AllowedSchemes = ["https"];
// });
In the same experiment the result became 500 5.065845s: two attempts were cut at 2 seconds each, and the total timeout ended the request at 5 seconds. ServiceDefaults is the place for decisions of the "same in every Bookstore service" kind. If a service wants a different budget, it can override it in its own AddHttpClient registration.
A Gateway with YARP: Project or Resource?
So that Angular doesn't have to know every service separately, we put a gateway in front. With YARP there are two ways. In Bookstore we wrote the gateway as a small ASP.NET Core project:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddServiceDiscoveryDestinationResolver();
WebApplication app = builder.Build();
app.MapDefaultEndpoints();
app.MapReverseProxy();
app.Run();
"ReverseProxy": {
"Routes": {
"books": { "ClusterId": "catalog", "Match": { "Path": "/api/books/{**rest}" } },
"orders": { "ClusterId": "ordering", "Match": { "Path": "/api/orders/{**rest}" } }
},
"Clusters": {
"catalog": { "Destinations": { "catalog": { "Address": "https+http://catalog" } } },
"ordering": { "Destinations": { "ordering": { "Address": "https+http://ordering" } } }
}
}
The destination addresses use the same https+http:// form. Because YARP uses its own HttpClient, the handler above doesn't apply there; the addresses are resolved by AddServiceDiscoveryDestinationResolver() from the Microsoft.Extensions.ServiceDiscovery.Yarp package. Since the gateway is a project, code such as authentication, rate limiting or response aggregation can be added here later; YARP itself is the topic of the API Gateway ve YARP series.
The second way is Aspire's YARP hosting integration (Aspire.Hosting.Yarp). The gateway runs as a container and the routes are written in code in the AppHost. We tried it in a copy:
builder.AddYarp("edge")
.WithConfiguration(yarp =>
{
yarp.AddRoute("/api/books/{**catch-all}", catalog);
yarp.AddRoute("/api/orders/{**catch-all}", ordering);
});
The container started with the mcr.microsoft.com/dotnet/nightly/yarp:2.3-preview image and forwarded a /api/books/9780441172719 request to Catalog with 200. The routes from the AppHost reach the container as environment variables:
REVERSEPROXY__ROUTES__route0__MATCH__PATH = /api/books/{**catch-all}
REVERSEPROXY__ROUTES__route0__CLUSTERID = cluster_catalog
REVERSEPROXY__CLUSTERS__cluster_catalog__DESTINATIONS__destination1__ADDRESS = http://catalog
services__catalog__http__0 = http://aspire.dev.internal:5080
The integration has two more helpers for Angular: WithStaticFiles(path) binds a folder to the container's /wwwroot in run mode, and PublishWithStaticFiles(web) generates, at publish time, a Dockerfile that copies Angular's build output into the gateway image. That way the same gateway serves Angular in production without a separate web server; we'll see it in the publish output in 07. Which one to choose? If the gateway only has routes, the resource is less code. If you're going to write your own code in the gateway (or the image coming from the nightly channel bothers you), the project is more flexible.
When a Service's Port Changes
We tried the last item of the plan too: we moved the port in Catalog's launch profile from 5080 to 5090 and restarted the AppHost. We didn't touch any other file.
order via gateway: 201
books via gateway: 200
CATALOG_HTTP = http://localhost:5090
services__catalog__http__0 = http://localhost:5090
Ordering and the gateway got the new address from environment variables. Catalog's port never appears in the code, in appsettings.json or in Angular, so there was nothing to change. After the experiment we set the port back to 5080.
In Production
Locally, Aspire's environment variables feed service discovery; decide up front who feeds it in production. In Kubernetes and Azure Container Apps the platform DNS and pass-through are enough. If you want to spread calls across several instances on the client side, you can write several addresses into configuration or use the DNS SRV provider (AddDnsSrvServiceEndpointProvider) for Kubernetes.
Open the health endpoints in production deliberately. When you remove the IsDevelopment() condition, add host filtering or authorization, a short timeout and a short-lived output cache; don't put external dependency checks on /alive, or a problem in Redis turns into all pods being restarted.
Set the resilience budget according to the call chain. In the gateway → Ordering → Catalog chain, if every link waits 30 seconds, the outermost client gives up much earlier. The total timeout of internal calls should be shorter than the timeout of the request that makes them.
Common Pitfalls
Forgetting WithReference. With no entry in configuration, pass-through kicks in, and the error doesn't say "service discovery doesn't work"; it shows up as a DNS error saying no host named catalog could be found. When you see such an error, check the reference in the AppHost first.
Trusting the AllowedSchemes comment in the template. As we saw above, without AllowAllSchemes = false the restriction doesn't work and doesn't raise an error either.
Assuming a stopped resource's port is closed. The DCP proxy keeps listening on the port; the connection is accepted but no answer comes. That's why you see timeouts rather than "connection refused" in local experiments.
Giving a session-lifetime container a fixed name and restarting the AppHost right away. With WithContainerName("aspire-redis"), when we ran aspire stop and aspire start right after, the new container was created before the old one was removed: Conflict. The container name "/aspire-redis" is already in use, and Redis stayed "Exited". Waiting a few seconds or restarting it with aspire resource redis start is enough; if you don't need a fixed name, it's easier not to set one.
Writing the gateway route incompletely. /api/books/{**rest} matches both /api/books and /api/books/9780441172719 (both returned 200); if you write a single-segment route such as /api/books/{isbn}, the list request becomes a 404 at the gateway.
A Small Experiment
★☆☆ Set Ordering's log level to Microsoft.Extensions.ServiceDiscovery=Debug and place two orders. Does the "Resolving endpoints" line appear again for the second order? Where might the resolved addresses be kept, and for how long?
★★☆ Run the AppHost with the https launch profile (run aspire certs trust first). Is there a services__catalog__https__0 among the variables Ordering gets? Which path does resolution come from in the debug log?
★★★ Add a second endpoint to Catalog with WithHttpEndpoint(port: 5081, name: "admin") and write a second typed client in Ordering that goes to https+http://_admin.catalog. How does the admin endpoint appear in the variables the AppHost gives Ordering?
Now there are two services, a gateway, a database and a cache; it's time to see how a request travels between them. In the next post we move to the dashboard: structured logs, the trace waterfall, metrics, aspire otel queries from the CLI, the persistent telemetry that came with 13.6, and using the dashboard on its own, without Aspire.