Skip to main content
SEMastery
DevOpsbeginner

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.

11 min readUpdated April 30, 2026

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.

Hardcoded URLs mean every environment needs a different value, which is fragile.

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:

  1. There is no port number.
  2. There is no real host like localhost.
  3. The word catalog is 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

You call catalog
Resolver looks up name
Real address found
Request is sent

Steps

1

You call catalog

Code uses the logical name

2

Resolver looks up name

Aspire checks its sources

3

Real address found

e.g. localhost:5001

4

Request is sent

HttpClient calls the real host

The friendly name is turned into a real address at the last moment.

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.

The AppHost connects services and passes addresses only where WithReference is used.

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 variableExample valueMeaning
Services__catalog__https__0https://localhost:5001HTTPS address of catalog
Services__catalog__http__0http://localhost:5002HTTP address of catalog
Services__basket__https__0https://localhost:6001HTTPS 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 writeWhat it means
https+http://catalogDefault endpoint of catalog
https://_admin.catalogThe named "admin" endpoint of catalog
http://_dashboard.basketThe 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.

A single service can expose several named endpoints that you target separately.

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://catalog would try to reach a host literally called catalog.

Resolver chain: first match wins

Configuration
DNS SRV
Pass-through
Address ready

Steps

1

Configuration

Check Services__ values

2

DNS SRV

Ask DNS if config was empty

3

Pass-through

Use name as host as last resort

4

Address ready

HttpClient uses the result

Each resolver gets a turn. The moment one finds an address, the chain stops.

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.

The logical name stays fixed while the resolver changes per environment.

This table makes the comparison clear.

ConcernHardcoded URLsAspire service discovery
Changing environmentsEdit code or config each timeSame code everywhere
PortsMemorised and copied aroundHidden behind a name
HTTPS vs HTTPFixed by handhttps+http:// picks for you
KubernetesManual DNS jugglingDNS SRV resolver handles it
Risk of typosHighLow, 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

Frontend calls catalog
Resolver reads config
Real address chosen
Catalog answers

Steps

1

Frontend calls catalog

BaseAddress is https+http://catalog

2

Resolver reads config

Finds Services__catalog__https__0

3

Real address chosen

https://localhost:5001

4

Catalog answers

Products come back as JSON

From a logical name in code to a real network call.

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. The https+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__0 style configuration that the resolver reads.
  • Turn it on with AddServiceDiscovery() in ServiceDefaults, often applied to every HttpClient.
  • Use scheme://_endpointName.serviceName to 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

Related Posts