How .NET Aspire Simplifies Service Discovery for Your Apps
Learn how .NET Aspire service discovery lets your services find each other by name, with no hardcoded URLs, ports, or environment headaches.
Imagine you move to a new hostel in a big city. Every week your friends change rooms. If you wrote down "Ravi lives in Room 214" on paper, that note becomes wrong the moment Ravi moves. But the hostel has a friendly warden at the front desk. You just say "I want to meet Ravi," and the warden tells you the correct room for today. You never memorise room numbers again. You only remember names.
Service discovery in software works the same way. Your services are like the students. Their addresses (host and port) keep changing, especially in the cloud. Instead of hardcoding addresses, you ask a friendly helper by name. That helper gives you the correct address right now. .NET Aspire is that warden for your apps, and it makes the whole thing feel almost invisible.
In this post you will learn what service discovery is, why hardcoded URLs hurt, and exactly how Aspire solves the problem with just a few lines of code.
The problem: hardcoded addresses break
Let us say you build a small shopping app. It has two parts:
- A web frontend that customers see.
- A catalog API that holds the list of products.
The frontend needs to call the catalog. The old way looks like this.
// The painful old way - a hardcoded address
var http = new HttpClient
{
BaseAddress = new Uri("https://localhost:5001")
};
var products = await http.GetFromJsonAsync<List<Product>>("/products");This works on your laptop. Then trouble starts:
- On a teammate's machine the catalog runs on a different port.
- In a test server the address is
https://catalog-test.internal:7000. - In production it is
https://catalog.prod.cloudapp.net:443.
Every time the address changes, someone edits code or juggles config files. People forget. Things break. This is the famous "works on my machine" problem.
The real product never changed. Only its address changed. So we should not put the address inside our code at all. We should put a name there instead.
The Aspire idea: call services by name
With .NET Aspire you write the address as a friendly logical name. Look at this version.
// The Aspire way - call the service by its name
var http = new HttpClient
{
BaseAddress = new Uri("https+http://catalog")
};
var products = await http.GetFromJsonAsync<List<Product>>("/products");Notice three things:
- There is no port number.
- There is no real host like
localhost. - The word
catalogis just the name of the service.
The piece https+http:// is a small instruction. It means: "Please use HTTPS if you can. If there is no HTTPS address, use HTTP instead." So you do not lock yourself into one scheme. Aspire picks the best one that exists.
When the request runs, Aspire looks up the name catalog, finds the correct address for the current environment, and quietly replaces the name with that address. Your code never knows or cares what the real address is.
What happens when you call https+http://catalog
Steps
You call catalog
Code uses the logical name
Resolver looks up name
Aspire checks its sources
Real address found
e.g. localhost:5001
Request is sent
HttpClient calls the real host
How it fits together in an Aspire app
A .NET Aspire solution usually has a few projects. The most important one for our topic is the AppHost. The AppHost is like the warden's office. It knows about every service and how they connect.
Here is a tiny AppHost that wires the frontend to the catalog.
// AppHost project - Program.cs
var builder = DistributedApplication.CreateBuilder(args);
// Register the catalog API as a service
var catalog = builder.AddProject<Projects.CatalogApi>("catalog");
// Register the frontend, and tell it about the catalog
builder.AddProject<Projects.WebFrontend>("webfrontend")
.WithReference(catalog);
builder.Build().Run();The magic word here is WithReference(catalog). It tells Aspire: "The frontend is allowed to talk to the catalog, so please share the catalog's address details with the frontend."
This one detail is important. Aspire only shares addresses for services you actually reference. If you forget WithReference, the frontend will not know how to find the catalog. Think of it as the warden only giving you a room number for friends you are allowed to visit.
What Aspire injects behind the scenes
You might wonder how the name catalog turns into a real address. The answer is plain configuration. When the app starts, Aspire sets environment variables for the referencing project. They look like this.
| Environment variable | Example value | Meaning |
|---|---|---|
Services__catalog__https__0 | https://localhost:5001 | HTTPS address of catalog |
Services__catalog__http__0 | http://localhost:5002 | HTTP address of catalog |
Services__basket__https__0 | https://localhost:6001 | HTTPS address of basket |
The double underscores are just how .NET writes nested configuration keys. The resolver reads these values. When your code asks for https+http://catalog, the resolver finds Services__catalog__https__0, sees https://localhost:5001, and uses it. If no HTTPS value existed, it would use the HTTP one instead.
The best part: in production these same variable names hold production addresses. The names stay. Only the values change. So your code stays exactly the same everywhere.
Turning service discovery on in a service
Aspire projects usually share a small helper project called ServiceDefaults. It adds common things like logging, health checks, and service discovery in one place. The key line is AddServiceDiscovery.
// ServiceDefaults - Extensions.cs (trimmed)
public static IHostApplicationBuilder AddServiceDefaults(
this IHostApplicationBuilder builder)
{
// Turn on service discovery for the whole app
builder.Services.AddServiceDiscovery();
// Make every HttpClient use service discovery by default
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddServiceDiscovery();
});
return builder;
}Because of ConfigureHttpClientDefaults, every HttpClient you create can resolve logical names automatically. You do not have to wire each client by hand. This is why the frontend example earlier "just worked".
If you build a typed client, it looks just as clean.
// Registering a typed client that uses a logical name
builder.Services.AddHttpClient<CatalogClient>(client =>
{
// Logical name, not a real address
client.BaseAddress = new Uri("https+http://catalog");
});Named endpoints: more than one door
Some services have more than one address. For example, a basket service might have a normal API endpoint and a separate dashboard endpoint. Aspire lets you ask for a specific named endpoint using an underscore.
You write it as scheme://_endpointName.serviceName. For example:
| What you write | What it means |
|---|---|
https+http://catalog | Default endpoint of catalog |
https://_admin.catalog | The named "admin" endpoint of catalog |
http://_dashboard.basket | The named "dashboard" endpoint of basket |
The underscore before the name is required. It tells the resolver "this is an endpoint name, not a website subdomain." Without it, the resolver would think admin.catalog is a real domain.
Where do the addresses come from? Resolvers
Aspire does not have just one way to find addresses. It uses small helpers called resolvers (also called providers). They are tried one after another, in order. The first resolver that finds an address wins, and the rest are skipped.
The common resolvers are:
- Configuration resolver - reads the
Services__...values we saw above. This is what makes local development easy. - DNS SRV resolver - asks DNS for special SRV records. This is great inside Kubernetes, where services are registered in DNS.
- Pass-through resolver - if nobody else found anything, it just uses the name as a normal host. So
http://catalogwould try to reach a host literally calledcatalog.
Resolver chain: first match wins
Steps
Configuration
Check Services__ values
DNS SRV
Ask DNS if config was empty
Pass-through
Use name as host as last resort
Address ready
HttpClient uses the result
For Kubernetes you would add the DNS SRV provider so named endpoints resolve from cluster DNS.
// Add DNS SRV resolution, useful inside Kubernetes
builder.Services.AddServiceDiscoveryCore();
builder.Services.AddDnsSrvServiceEndpointProvider();Because resolvers run in order, you can mix them. During local development the configuration resolver answers everything. In the cluster, DNS SRV takes over. Same code, different resolver. That flexibility is a big reason Aspire feels so smooth.
Local development vs production, side by side
Here is the whole point of service discovery in one picture. The logical name never changes. Only the source of the address changes.
This table makes the comparison clear.
| Concern | Hardcoded URLs | Aspire service discovery |
|---|---|---|
| Changing environments | Edit code or config each time | Same code everywhere |
| Ports | Memorised and copied around | Hidden behind a name |
| HTTPS vs HTTP | Fixed by hand | https+http:// picks for you |
| Kubernetes | Manual DNS juggling | DNS SRV resolver handles it |
| Risk of typos | High | Low, you only type a name |
A small but important safety note on libraries
When you build microservices you often reach for messaging and mediator libraries. Two very popular ones, MediatR and MassTransit, have moved to a commercial license in their recent versions. That does not change anything about Aspire service discovery, but it is good to know before you add them to a new project. Check the license and pricing if you plan to use newer releases in a paid product. For learning and small apps, read their current terms first so there are no surprises later.
Service discovery itself lives in the open-source Microsoft.Extensions.ServiceDiscovery package, so the core feature we covered here is free to use.
Putting it all together
Let us trace one full request from start to finish, so the whole flow sits clearly in your mind.
Full request flow with Aspire service discovery
Steps
Frontend calls catalog
BaseAddress is https+http://catalog
Resolver reads config
Finds Services__catalog__https__0
Real address chosen
https://localhost:5001
Catalog answers
Products come back as JSON
The frontend code only ever spoke about catalog. Aspire did all the boring address work. If tomorrow the catalog moves to a new server, you change one configuration value, not your code.
This is the gift of service discovery. Your code talks about what it wants, not where it lives. The "where" is decided fresh, every time, for the environment you are in. That is exactly how our friendly hostel warden behaved at the start of this post.
If you are just starting with .NET Aspire, the good news is that the project templates already set up ServiceDefaults and AddServiceDiscovery for you. You mostly just add WithReference in the AppHost and use logical names in your HttpClient. The rest is handled.
Quick recap
- Service discovery lets a service find another by name instead of by a fixed address.
- Hardcoded URLs break across machines and environments. Names do not.
- In Aspire, write addresses like
https+http://catalog. Thehttps+http://part means "HTTPS first, then HTTP". - In the AppHost, use
WithReference(catalog)so the address is shared with the caller. - Aspire injects
Services__catalog__https__0style configuration that the resolver reads. - Turn it on with
AddServiceDiscovery()in ServiceDefaults, often applied to everyHttpClient. - Use
scheme://_endpointName.serviceNameto target a named endpoint. - Resolvers run in order: Configuration, DNS SRV, Pass-through. First match wins.
- The same code works in local development and production because only configuration changes.
- The core feature is free in
Microsoft.Extensions.ServiceDiscovery. Note that MediatR and MassTransit are now commercially licensed, so check their terms separately.
References and further reading
- Aspire service discovery overview - Microsoft Learn
- Service discovery in .NET - Microsoft Learn
- Aspire inner loop networking overview - Microsoft Learn
- Microsoft.Extensions.ServiceDiscovery on NuGet
- How .NET Aspire Simplifies Service Discovery - Milan Jovanovic
Related Posts
.NET Aspire: A Game Changer for Cloud-Native Development
A beginner-friendly guide to .NET Aspire, the cloud-native stack that orchestrates your services, databases, and dashboards with one simple command.
Getting Started With .NET Aspire 13: Building and Deploying an App
A beginner-friendly guide to .NET Aspire 13: build a small app with PostgreSQL and Redis, watch it run on the dashboard, then deploy with Docker Compose.
Service Discovery in .NET Microservices with HashiCorp Consul
A beginner-friendly guide to service discovery in .NET microservices using HashiCorp Consul, with registration, health checks, and lookups explained simply.
Getting Started With Dapr for Building Cloud-Native Microservices in .NET
A beginner-friendly guide to Dapr for .NET developers: learn sidecars, state, pub/sub, and service invocation to build cloud-native microservices.
Introduction to Dapr for .NET Developers: A Beginner Guide
A warm, beginner-friendly introduction to Dapr for .NET developers, covering sidecars, building blocks, state, pub/sub, and service invocation in plain C#.
Implementing API Gateway Authentication With YARP in .NET
Learn to build a secure API gateway in .NET using YARP. Add authentication, per-route authorization policies, and pass user identity to backend services.