Skip to content
Kumar Chandrachooda
.NET

Three Load Balancers Behind One Config String

DShop.Common picks client-side discovery, a router, or a static address from one word in JSON - and self-registers each service into Consul with a heartbeat lease.

By Kumar Chandrachooda 19 Sep 2025 5 min read
One switch routing a request to three different discovery strategies

The last two parts were about error handling; this one is about how a service call finds its destination at all. In a microservices estate, a call from the gateway to products cannot hardcode a host — there might be three products instances, on ports assigned by Docker, coming and going. DShop.Common solves this with a single registration method and a switch on one configuration string, and the result is that you can move the entire estate between client-side discovery, a routing load balancer, and static addressing by editing one word. It is the Strategy pattern applied to service discovery, and reading it is a clean tour of three genuinely different system-design choices.

One method, three strategies

Every typed HTTP client in the estate is registered through RegisterServiceForwarder<T>. The whole dispatch is a switch:

public static void RegisterServiceForwarder<T>(this IServiceCollection services, string serviceName)
    where T : class
{
    var clientName = typeof(T).ToString();
    var options = ConfigureOptions(services);
    switch (options.LoadBalancer?.ToLowerInvariant())
    {
        case "consul": ConfigureConsulClient(services, clientName, serviceName); break;
        case "fabio":  ConfigureFabioClient(services, clientName, serviceName);  break;
        default:       ConfigureDefaultClient(services, clientName, serviceName, options); break;
    }
    ConfigureForwarder<T>(services, clientName);
}

The string restEase.loadBalancer in appsettings.json"consul", "fabio", or anything else — decides which of three worlds the service lives in. ConfigureForwarder<T> then wraps whichever HttpClient resulted in a RestEase typed proxy, so callers just inject IProductsService and call methods; the discovery strategy underneath is invisible to them. That invisibility is the point of the Strategy pattern, and it is done well here.

Strategy one: client-side discovery

"consul" attaches a DelegatingHandler to the HTTP client that rewrites every outbound request to a live instance:

private async Task<Uri> GetRequestUriAsync(HttpRequestMessage request, string serviceName, Uri uri)
{
    var service = await _servicesRegistry.GetAsync(serviceName);
    if (service == null)
        throw new ConsulServiceNotFoundException(..., serviceName);

    var uriBuilder = new UriBuilder(uri) { Host = service.Address, Port = service.Port };
    return uriBuilder.Uri;
}

The client itself asks Consul "who is products?", picks an instance, and rewrites http://products/... to http://172.20.0.4:5000/...client-side load balancing, the caller doing the choosing. It wraps the send in a Polly retry with exponential backoff (2^attempt seconds), and it even patches Docker's host.docker.internal-style hostnames back to localhost for local runs. The instance-selection logic — how it avoids hammering the same box — is the next part's subject, and it has a memory problem.

Strategy two: a router

"fabio" attaches a much simpler handler that points the client at Fabio — a routing load balancer that sits in front of the services and reads Consul's registry itself. Here the client does no selection; it sends to Fabio and Fabio picks the instance. This is server-side load balancing, and the estate wires it with a tag it plants at registration time (more on that below): urlprefix-/products strip=/products. That one tag is the entire contract between Consul and Fabio — Fabio watches Consul, sees the tag, and starts routing /products to whatever is registered. Two strategies that are architecturally opposite — the caller chooses vs a proxy chooses — behind the same typed client interface.

Strategy three: static

Anything else falls to ConfigureDefaultClient, which reads a fixed host/port out of restEase.services config and sets it as the client's base address, throwing RestEaseServiceNotFoundException if the name isn't listed. No discovery, no balancing — the classic “it's just one box in dev” mode. Useful, honest, and the reason the estate runs on a laptop without Consul.

Three strategies at a glance

The three modes are not just three code paths; they are three genuinely different systems with different failure surfaces, and the single config string hides that. Laid out plainly:

Mode Who chooses the instance Needs Fails when
consul the caller (client-side) Consul reachable, services self-registered Consul is down, or the registry picks a just-dead instance before its lease expires
fabio a router in front (server-side) Fabio + Consul, the urlprefix tag Fabio is down; one more hop to operate and monitor
default nobody — fixed address the host/port in config the box moves, or there is more than one of it

The point of the table is that “load balancing” is three different contracts here, and choosing between them is an architecture decision — client-side discovery pushes selection and resilience into every caller; a router centralises it behind one more moving part; static is only honest for a single instance. Collapsing all three behind one word in JSON is ergonomically lovely and operationally lossy: nothing at the call site tells you which of these three worlds you are in, so a failure reads very differently depending on a string you have to go and look up.

The other half: advertising availability

Client-side discovery only works if services register themselves, and UseConsul is where each service announces its existence on startup:

var registration = new AgentServiceRegistration
{
    Name = serviceName,
    ID = $"{serviceName}:{uniqueId}",
    Address = address,
    Port = port,
    Tags = fabioOptions.Value.Enabled ? GetFabioTags(...) : null
};
if (consulOptions.Value.PingEnabled || fabioOptions.Value.Enabled)
{
    registration.Checks = new[] { new AgentServiceCheck
    {
        Interval = TimeSpan.FromSeconds(pingInterval),
        DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(removeAfterInterval),
        HTTP = $"{scheme}{address}:{port}/{pingEndpoint}"
    }};
}
client.Agent.ServiceRegister(registration);

Read as a set of distributed-systems patterns, this is textbook and worth naming:

  • Advertise Availability — the service registers itself with a unique id (products:{guid}), so N instances coexist in the registry.
  • Heartbeat / health check — Consul calls the pingEndpoint every Interval seconds to confirm the instance is alive.
  • LeaseDeregisterCriticalServiceAfter means a service that stops answering its health check is automatically removed after the interval. Registration is a lease, not a permanent fact; miss enough heartbeats and Consul evicts you.

That trio is exactly what a discovery system needs, and the estate implements it in one method. The Fabio integration rides on the same registration — GetFabioTags returns the urlprefix tag, so enabling Fabio just adds a tag to the Consul entry and the router picks it up. One registration feeds both load-balancing strategies.

The honest note

The Strategy design is genuinely good — three discovery models, one call site, callers oblivious. Two things a reader should carry forward, neither fatal. First, the health check served at pingEndpoint is the same endpoint the metrics middleware owns, which means disabling metrics silently 404s your health check and Consul evicts the service — a coupling I unpack in the third series. Second, the strategy is chosen once, globally, by a single string; there is no per-service override, so the whole estate is consul-or-fabio-or-static together. For a teaching shop that is fine; for a real system you often want the gateway on client-side discovery and internal calls behind a router, which this shape can't express without more knobs.

The instance-picking inside the Consul strategy is where the interesting bug lives — a registry that carefully tracks which instances it has already used, and then throws that memory away on almost every request. That is next.