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.
Think about a busy railway station in India. Trains arrive and leave all day. Platform numbers change. If you wrote on a piece of paper "the Chennai train is always on Platform 3," that note would be wrong tomorrow. So you do not trust the paper. You look at the big enquiry board, or you ask the enquiry counter. The board always shows the correct platform for right now.
Microservices have the same problem. Each service runs on some address and port. In the cloud, these addresses change all the time. Services start, stop, move, and multiply. If you write a fixed address in your code, that address becomes wrong the moment the service moves. You need an enquiry board for your services. HashiCorp Consul is that enquiry board.
In this post you will learn what service discovery is, why fixed addresses break, and exactly how to use Consul with .NET to register a service, check its health, and look it up by name. We will keep the language simple and the steps small.
What is service discovery?
Service discovery is a simple idea with a fancy name. It means: one service finds the address of another service by asking, instead of by remembering.
Let us say you have a shopping app made of small services:
- An orders service that takes orders.
- A payments service that charges money.
- A shipping service that sends parcels.
The orders service needs to call payments. But where is payments today? On which machine? On which port? If there are three copies of payments running for speed, which copy should orders use?
A fixed URL cannot answer these questions, because the answers keep changing. Service discovery answers them at runtime.
There are two common styles of service discovery.
| Style | Who looks up the address | Simple meaning |
|---|---|---|
| Client-side | The calling service itself | Orders asks Consul, then calls payments directly. |
| Server-side | A load balancer or gateway | Orders calls a gateway, and the gateway asks Consul. |
In this post we focus on the client-side style, because it is the easiest one to understand and to try on your own machine.
Meet HashiCorp Consul
Consul is a tool made by HashiCorp. It does a few useful jobs at once.
- It keeps a registry: a live list of every service, its address, and its port.
- It runs health checks: it keeps asking each service "are you okay?" on a schedule.
- It answers lookups: it tells you the healthy addresses for a service name, over HTTP or DNS.
- It also offers a key value store for small bits of shared settings.
The most important habit Consul gives you is this: Consul never returns an instance that is failing its health check. So when you ask "where is payments?", you only get copies that are actually working. That single rule prevents a lot of pain.
Consul runs as an agent on each machine. Your .NET service talks to the agent that sits next to it, usually at http://localhost:8500. The agents gossip with each other and keep a shared picture of the whole system.
The three jobs: register, check, look up
Everything we do with Consul fits into three small jobs. Keep these three words in your head and the rest is easy.
The Consul lifecycle for one service
Steps
Register
Tell Consul my name, address, port, and health URL
Health check
Consul keeps calling my health endpoint on a timer
Look up
Other services ask Consul for healthy copies of me
Deregister
On shutdown, remove myself from the registry
Let us walk through each job in order.
Job 1: Register the service
When your .NET service starts, it tells the local Consul agent who it is. It sends a small block of data: a name, an id, an address, a port, and the URL of a health endpoint. Consul stores this in the registry.
We will use the Consul.NET client library, which is a thin wrapper over the Consul HTTP API. First add the package.
dotnet add package ConsulNow register the service when the app starts. The code below registers a payments service and points Consul at a /health endpoint.
using Consul;
var builder = WebApplication.CreateBuilder(args);
// One Consul client for the whole app, pointing at the local agent.
builder.Services.AddSingleton<IConsulClient>(_ =>
new ConsulClient(config =>
{
config.Address = new Uri("http://localhost:8500");
}));
var app = builder.Build();
// Pick a stable id so we can deregister later.
var serviceId = $"payments-{Guid.NewGuid()}";
var registration = new AgentServiceRegistration
{
ID = serviceId,
Name = "payments", // the friendly name others will ask for
Address = "localhost", // where this copy lives
Port = 5001, // the port this copy listens on
Check = new AgentServiceCheck
{
HTTP = "http://localhost:5001/health",
Interval = TimeSpan.FromSeconds(10), // ask every 10 seconds
Timeout = TimeSpan.FromSeconds(5), // give up after 5 seconds
DeregisterCriticalServiceAfter = TimeSpan.FromMinutes(1)
}
};
// Register when the app has started.
var consul = app.Services.GetRequiredService<IConsulClient>();
await consul.Agent.ServiceRegister(registration);
app.MapGet("/health", () => Results.Ok("healthy"));
app.Run();A few things to notice. The Name is "payments". Many copies can share this same name, and that is the point. The ID is unique per copy, so Consul can tell the copies apart. The Check tells Consul how to confirm the copy is alive.
Job 2: Let Consul check health
Once registered, Consul does not just trust your service forever. It keeps calling the health URL every ten seconds. If the answer is a 2xx status code, the copy is passing. If the call fails or times out, the copy starts to look sick.
You can make the health endpoint smarter. ASP.NET Core has a built-in health checks system that can test the database, disk, or anything else before saying "healthy". A simple version looks like this.
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());
var app = builder.Build();
// Now /health reports the result of all registered checks.
app.MapHealthChecks("/health");Here is the rule worth repeating: a copy that fails its health check is hidden from lookups. So if payments copy 2 loses its database connection, Consul stops handing it out, and orders never talks to a broken copy.
Job 3: Look up a service by name
Now the fun part. The orders service wants to call payments, but it does not know any address. It asks Consul for the healthy copies of "payments". Consul replies with a list. Orders picks one and calls it.
using Consul;
public class PaymentsLookup
{
private readonly IConsulClient _consul;
public PaymentsLookup(IConsulClient consul) => _consul = consul;
public async Task<Uri> GetHealthyAddressAsync()
{
// passingOnly: true means only healthy copies come back.
var result = await _consul.Health.Service("payments", tag: null,
passingOnly: true);
var entries = result.Response;
if (entries.Length == 0)
throw new InvalidOperationException("No healthy payments copy found.");
// Simple round-robin or random pick across the healthy copies.
var chosen = entries[Random.Shared.Next(entries.Length)];
var service = chosen.Service;
return new Uri($"http://{service.Address}:{service.Port}");
}
}Notice passingOnly: true. That single flag is what keeps traffic away from sick copies. The orders service does not need to know how many copies exist or where they live. It just asks by name and gets a working address.
A call from orders to payments
Steps
Ask Consul
Health.Service('payments', passingOnly: true)
Get healthy list
Consul returns only working copies
Pick one
Choose by random or round-robin
Call it
Send the HTTP request to that address
Cleaning up: deregister on shutdown
When a service stops, it should remove itself from the registry. If it does not, Consul will eventually notice the failing health checks and clean it up using DeregisterCriticalServiceAfter. But a polite shutdown is faster and cleaner.
// Run this when the app is stopping.
app.Lifetime.ApplicationStopping.Register(() =>
{
var consul = app.Services.GetRequiredService<IConsulClient>();
consul.Agent.ServiceDeregister(serviceId).GetAwaiter().GetResult();
});This matters during deploys. When you roll out a new version, old copies shut down and new copies start. Clean deregistration means there is never a window where Consul points traffic at a copy that has already gone away.
Running Consul on your machine
You do not need a cloud account to try this. You can run a single Consul agent in development mode with Docker.
docker run -d --name consul -p 8500:8500 hashicorp/consul agent -dev -client=0.0.0.0After it starts, open http://localhost:8500 in your browser. You will see the Consul UI. When your .NET service registers, it will appear in the Services list with a green tick if its health check is passing. This dashboard is the easiest way to learn, because you can watch services appear and disappear in real time.
How the whole picture fits together
Let us zoom out and see all the pieces at once. Two services register. Consul checks their health. Orders looks up payments and calls a healthy copy.
Here is a quick comparison of life before and after service discovery. It shows why teams take the trouble to add Consul.
| Question | Without service discovery | With Consul |
|---|---|---|
| Where is the other service? | Hardcoded in config or code | Asked by name at runtime |
| What if it moves? | You edit and redeploy | Nothing to change |
| What if a copy is broken? | You might still call it | It is hidden automatically |
| How do you add more copies? | Update every caller's config | Just start them; they register |
| How do you know what is healthy? | You guess or check by hand | Consul checks on a schedule |
A few tips for real projects
These small habits will save you trouble later.
- Always set a health check. A service without a health check looks healthy to Consul even when it is broken. The health check is your safety net.
- Use stable, unique service ids. If every restart creates a brand new id and the old one is never removed, the registry fills with ghosts. Deregister on shutdown, and let
DeregisterCriticalServiceAftercatch the rest. - Do not hardcode
localhostin production. During local testinglocalhostis fine. In the cloud, register the real reachable address of the container or host. - Cache lookups for a short time. Asking Consul on every single request adds load. Many teams cache the healthy list for a few seconds and refresh it. The Consul .NET client also supports blocking queries that wait for changes.
- Combine with retries. Even healthy copies sometimes fail a single call. Pair Consul lookups with a retry policy so one hiccup does not break a request.
How does this compare to .NET Aspire?
You may have seen that .NET Aspire also offers service discovery. The idea is the same: call a service by name, not by address. The difference is in scope. Aspire's built-in discovery is great for local development and simple deployments, and it can read addresses from configuration without any extra server. Consul is a separate, language-neutral system that also does health checking, a key value store, and service mesh features, and it works the same way whether the caller is .NET, Java, Go, or anything else. Many teams use Aspire while building locally and Consul (or a similar registry) in larger production setups. They are not enemies; they solve the same problem at different sizes.
One small note on licensing, since people often ask: the popular .NET messaging libraries MassTransit and MediatR moved to a commercial license for their newer versions. That change does not affect Consul or the Consul .NET client, which are separate projects. Just keep it in mind if you plan to combine service discovery with one of those messaging tools in the same app.
Quick recap
- Service discovery lets one service find another by name, not by a fixed address, so moving and scaling services does not break callers.
- Consul is an enquiry board for your services. It keeps a registry, runs health checks, and answers lookups over HTTP or DNS.
- The whole job is three small steps: register on start, let Consul health check on a timer, and look up healthy copies by name before each call.
- Use
passingOnly: trueso you only ever get healthy copies, and Consul hides sick ones for you. - Deregister on shutdown, set a real health endpoint, and cache lookups a little to keep things fast.
- Consul and .NET Aspire solve the same problem; Aspire shines for local and simple setups, Consul shines for larger, mixed-language systems.
References and further reading
- Consul Service - Agent HTTP API (HashiCorp Developer)
- Register your services to Consul (HashiCorp tutorial)
- Catalog HTTP API (HashiCorp Developer)
- Service discovery in .NET (Microsoft Learn)
- Health checks in ASP.NET Core (Microsoft Learn)
- Service Discovery and Health Checks in ASP.NET Core with Consul (Michael Conrad)
Related Posts
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.
Health Checks in ASP.NET Core: A Beginner's Guide
Learn health checks in ASP.NET Core: add liveness and readiness endpoints, check your database and Redis, write custom checks, and wire up Kubernetes probes.
.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 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#.
Structured Logging and Distributed Tracing for Microservices with Seq
Learn to add structured logging with Serilog and distributed tracing with OpenTelemetry to .NET microservices, then view it all in Seq with one trace ID.