In this article, we’ll look at a new addition to .NET 8 APIs called Service Discovery. We’ll explore what it is, how it works, and how we can take full advantage of it.

To download the source code for this article, you can visit our GitHub repository.

Let’s dive in!

What Is Service Discovery in .NET?

Service discovery is a mechanism that allows us as developers to use logical names instead of IP addresses and ports to refer to external services in a networked environment. This simplifies the process of locating and interacting with services, especially in dynamic and distributed systems where services may come and go.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

The service discovery functionality works by utilizing named endpoints. Instead of 192.168.0.1:8080 we can use a name describing the service at this address, in our case, we can use the name shipping. Then for this named endpoint, we can provide one or more physical addresses and leave Service Discovery to figure out which one to use.

Pre-requisites and Project Setup

Before we can do anything, we need to install the required NuGet package:

dotnet add package Microsoft.Extensions.ServiceDiscovery --prerelease

We are using --prerelease as at the time of writing this article the package is still in preview.

To illustrate how this feature works, we will use two APIs. We will have one main API which will use Service Discovery to locate and call a Shipping API. We will run both APIs locally.

Our shipping API has one endpoint:

app.MapGet("/shiporder", () =>
{
    return $"Your order has been shipped at {DateTimeOffset.UtcNow}";
});

The shiporder endpoint returns a simple message with a timestamp.

Setting up Service Discovery in .NET

To set up Service Discovery, we first add it to our services:

builder.Services.AddServiceDiscovery();

We use the AddServiceDiscovery() method that we get from the Microsoft.Extensions.ServiceDiscovery namespace, to add Service Discovery to our IServiceCollection. By utilizing this method, we also add a configuration and pass-through service endpoint resolvers which we will tackle in this article.

If, for some reason, you don’t need that, you can opt for the AddServiceDiscoveryCore() extension method which only adds core Service Discovery functionality and you can add the endpoint resolvers yourself to the IServiceCollection.

Individual HTTP Client Configuration

Next, we have two options to configure Service Discovery for our HttpClient – we can configure a single client:

builder.Services.AddHttpClient("shipping", static client =>
{
    client.BaseAddress = new("https://shipping");
})
.UseServiceDiscovery();

Here, we use the AddHttpClient() extension method on our IServiceCollection to configure a named client. We set the base address of our “shipping” HttpClient to http://shipping. Then we add the UseServiceDiscovery() method to the HTTP client configuration to ensure that our endpoint will be automatically resolved.

Then, we use it to call our Shipping API:

app.MapGet("/callshippingapi", async (IHttpClientFactory factory) =>
{
    var client = factory.CreateClient("shipping");
    var response = await client.GetStringAsync("shiporder");

    return $"Shipping API returned: {response}";
});

We use IHttpClientFactory to create our named HttpClient “shipping” and call the shiporder endpoint. As part of our minimal API, we return a simple message and the response from the API.

Global HTTP Client Configuration

We can also configure the global defaults for all HttpClient instances we create:

builder.Services.ConfigureHttpClientDefaults(static client =>
{
    client.UseServiceDiscovery();
});

In this case, we opt for the ConfigureHttpClientDefaults() method and then use the UseServiceDiscovery() on our IHttpClientBuilder. This will ensure that all HttpClient instances in our application will use Service Discovery by default.

Then, we call our API:

app.MapGet("/callshippingapi", async (HttpClient client) =>
{
    var response = await client.GetStringAsync("https://shipping/shiporder");

    return $"Shipping API returned: {response}";
});

We use the HttpClient to call https://shipping/shiporder. The Service Discovery functionality resolves the address before producing the actual HTTP call.

This is the groundwork, but we still need one more stop.

How to Resolve Endpoints From Configuration?

Service Discovery uses the .NET configuration environment to resolve our named endpoints. We will use appsettings.json to configure our named endpoints but you can also use any other IConfiguration source like environment variables, Azure App Configuration, etc.

We start by updating our configuration:

"Services": {
  "shipping": [
    "https://localhost:7273",
    "https://localhost:7274"
  ]
}

To start, we create a Services section, and inside it, we add the shipping sub-section. It should be an array of strings, each representing a physical address for our service. Our Shipping API is listening on two ports, so we add them both. Every time we call the shipping endpoint, Service Discovery will use one of the provided addresses to make the actual HTTP call. After this is done, we can use HttpClient to make requests to our Shipping API and the address will be automatically resolved.

Regardless of the approach we use, we can inspect our application’s logs:

info: System.Net.Http.HttpClient.Default.LogicalHandler[100]
      Start processing HTTP request GET https://shipping/shiporder
info: System.Net.Http.HttpClient.Default.ClientHandler[100]
      Sending HTTP request GET https://localhost:7273/shiporder
info: System.Net.Http.HttpClient.Default.ClientHandler[101]
      Received HTTP response headers after 5.1416ms - 200

We can see that the GET call to http://shipping/shiporder was sent to https://localhost:7273/shiporder – one of the physical addresses from our appsettings.json file.

Utilizing Custom Resolver Configuration for Service Discovery in .NET

Using the AddServiceDiscovery() method adds the default configuration to Service Discovery’s endpoint resolver. But we have the option to customize those defaults:

builder.Services.Configure<ConfigurationServiceEndPointResolverOptions>(static options =>
{
    options.SectionName = "ServiceEndpoints";
    options.ApplyHostNameMetadata = endpoint =>
    {
        return endpoint.EndPoint is DnsEndPoint dns
            && !dns.Host.Contains("localhost");
    };
});

We apply our custom configuration using the ConfigurationServiceEndPointResolverOptions class. It allows us to configure two things – SectionName and ApplyHostNameMetadata.

The first thing we configure is the SectionName (it is set to Services by default) by setting it to ServiceEndpoints:

"ServiceEndpoints": { 
    "shipping": [
        "https://localhost:7273",
        "https://localhost:7274"
    ]
}

It’s essential that we also update the section name in the appsettings.json file, otherwise, Service discovery won’t work.

Then we update our ApplyHostNameMetadata property – it determines whether to apply host metadata or not. In our case, metadata will be applied only if the endpoint is of type DnsEndPoint and it’s Host doesn’t contain the word localhost.

Load-Balancing With Service Discovery in .NET

Every time Service Discovery resolves an endpoint for us, it selects a single address for our desired service. In a distributed system, more often than not, we might have many addresses for a single named endpoint. This makes it vital for us to balance the load to each physical address to prevent overloading. 

By default, when we use the UseServiceDiscovery() method to configure our HTTP clients, addresses are selected from the provided list on a round-robin basis. We can change that by passing an IServiceEndPointSelector instance to the UseServiceDiscovery() method.

Let’s see how we can change the default load-balancing strategy:

builder.Services.ConfigureHttpClientDefaults(static client =>
{
    client.UseServiceDiscovery(PickFirstServiceEndPointSelectorProvider.Instance);
});

We pass PickFirstServiceEndPointSelectorProvider.Instance to the UseServiceDiscovery() method in our default HTTP client configuration. This will apply the always-pick-first approach when picking an address.

Service Discovery in .NET provides a total of four load-balancing strategy providers:

  • PickFirstServiceEndPointSelectorProvider – as explained above, this will always opt for the first address on the list
  • RoundRobinServiceEndPointSelectorProvider – this approach selects one address after the other in the order we have provided them.
  • RandomServiceEndPointSelectorProvider – this strategy picks the addresses at random
  • PowerOfTwoChoicesServiceEndPointSelectorProvider – this provider will always try to pick the least loaded address by utilizing the Power of Two Choices algorithm

Platform-Provided Service Discovery and Order of Resolution

In scenarios where we need to take advantage of platform-provided functionalities, like those offered by Azure Container Apps, the need for an additional service discovery client library is eliminated. This approach proves beneficial when deploying applications in such environments.

The pass-through resolver plays a key role in this context by allowing the utilization of alternative resolvers, such as configuration across different environments. We achieve flexibility without requiring any code modifications. Operating without external resolution, the pass-through resolver returns the input service name represented as a DnsEndPoint. By default, the pass-through provider is configured when adding service discovery through the AddServiceDiscovery() extension method, there is nothing we need to do. If you have used the AddServiceDiscoveryCore() method instead, and want to take advantage of the pass-through provider, you need to call the AddPassThroughServiceEndPointResolver() extension method on the IServiceCollection. 

When resolving service endpoints, the registered resolvers follow the order of registration, giving each a chance to modify the ServiceEndPoints collection. Service Discovery providers skip resolution if there are existing endpoints in the collection, ensuring efficiency. 

Conclusion

In conclusion, the introduction of Service Discovery in .NET 8 brings a streamlined approach to handling service endpoints, allowing us to use logical names instead of IP addresses and ports. The flexibility provided by named endpoints simplifies the process of locating and interacting with services, especially in dynamic and distributed systems. We’ve covered setting up Service Discovery, configuring HTTP clients, resolving endpoints, and customizing resolver options as well as load-balancing strategies. In .NET, Service Discovery equips us with a powerful toolkit for navigating the intricacies of distributed environments.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!