CMD Guide
HomeSystem DesignMicroservices Patterns

Service Discovery Pattern An Example

Service discovery works because every running instance keeps a short-lived lease in a shared registry by sending periodic heartbeats; a caller asks the registry for the live addresses of a service, the registry returns only leases that have not expired, and the caller picks one instance itself — so a crashed instance disappears on its own once its lease lapses, with no central actor rewriting configuration.

The version below is deliberately richer than a map put/get. It carries multiple instances per service, heartbeat expiry (TTL), a load-balanced client-side pick, and a client cache — the four things that make discovery actually behave like discovery.

The registry: instance list + lease expiry

The registry is keyed by service name, but each value is now a set of instances, and each instance carries the timestamp of its last heartbeat. heartbeat both registers a new instance and renews an existing one; discover evicts anything older than the TTL before returning addresses. A Clock is injected so the lease logic is testable without sleep.

import java.time.Clock;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class ServiceRegistry {

    /** One running copy of a service. */
    static final class Instance {
        final String id;            // "orders-i-1"
        final String address;       // "10.0.0.11:8080"
        volatile long lastBeatMs;   // last heartbeat, epoch millis
        Instance(String id, String address, long now) {
            this.id = id; this.address = address; this.lastBeatMs = now;
        }
    }

    static final long TTL_MS = 15_000;   // drop an instance after 15s of silence

    // serviceName -> (instanceId -> Instance)
    private final ConcurrentHashMap<String, ConcurrentHashMap<String, Instance>> table
            = new ConcurrentHashMap<>();
    private final Clock clock;

    ServiceRegistry(Clock clock) { this.clock = clock; }

    /** First call registers; later calls from the same id renew the lease. */
    public void heartbeat(String service, String instanceId, String address) {
        long now = clock.millis();
        table.computeIfAbsent(service, k -> new ConcurrentHashMap<>())
             .compute(instanceId, (k, prev) -> {
                 if (prev == null) return new Instance(instanceId, address, now);
                 prev.lastBeatMs = now;      // renew
                 return prev;
             });
    }

    /** Live addresses only; stale instances are evicted lazily on read. */
    public List<String> discover(String service) {
        ConcurrentHashMap<String, Instance> instances = table.get(service);
        if (instances == null) return List.of();
        long cutoff = clock.millis() - TTL_MS;
        instances.values().removeIf(i -> i.lastBeatMs < cutoff);   // lazy eviction
        List<String> live = new ArrayList<>(instances.size());
        for (Instance i : instances.values()) live.add(i.address);
        return live;
    }
}

The nested ConcurrentHashMap lets many instances of the same service coexist, and lets heartbeat and discover run concurrently without locking. removeIf on values() is the whole eviction engine: no background thread, the dead lease is reaped the next time anyone reads.

The instance: self-registration by heartbeat

Each instance registers itself and then keeps beating on a schedule (here every 5 seconds, comfortably under the 15-second TTL). This is the self-registration variant — the instance is responsible for its own lease.

import java.time.Clock;
import java.util.concurrent.*;

public class Service {
    private final String name;         // "orders"
    private final String instanceId;   // "orders-i-1"
    private final String address;      // "10.0.0.11:8080"
    private final ServiceRegistry registry;
    private final ScheduledExecutorService beat =
            Executors.newSingleThreadScheduledExecutor();

    Service(String name, String instanceId, String address, ServiceRegistry registry) {
        this.name = name; this.instanceId = instanceId;
        this.address = address; this.registry = registry;
    }

    public void start() {
        // register immediately, then renew every 5s (TTL is 15s -> two misses tolerated)
        beat.scheduleAtFixedRate(
            () -> registry.heartbeat(name, instanceId, address),
            0, 5, TimeUnit.SECONDS);
    }

    public void stop() { beat.shutdownNow(); }   // beats cease; lease lapses in <= 15s
}

Because the TTL (15s) is three heartbeat intervals (5s), the instance can miss up to two beats — a GC pause, a slow network — without being wrongly evicted. That ratio is the single most important tuning knob in the whole pattern; more on it in Pitfalls.

The client: cache + load-balanced pick

The caller does three things the naive example skipped: it caches the instance list for a few seconds (so it is not hammering the registry on every call), it load-balances across instances with round-robin, and it fails loudly only when there is genuinely nothing live.

import java.time.Clock;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class ClientService {
    private final ServiceRegistry registry;
    private final Clock clock;
    private final AtomicInteger rr = new AtomicInteger();

    private static final long CACHE_TTL_MS = 5_000;
    private String cachedService;
    private List<String> cached = List.of();
    private long cachedAtMs = Long.MIN_VALUE;

    ClientService(ServiceRegistry registry, Clock clock) {
        this.registry = registry; this.clock = clock;
    }

    /** A load-balanced address, refreshing the local cache when it ages out. */
    public String pickInstance(String service) {
        List<String> live = lookup(service);
        if (live.isEmpty())
            throw new IllegalStateException("No live instances for " + service);
        int idx = Math.floorMod(rr.getAndIncrement(), live.size());  // never negative
        return live.get(idx);
    }

    private List<String> lookup(String service) {
        long now = clock.millis();
        boolean stale = !service.equals(cachedService)
                        || now - cachedAtMs > CACHE_TTL_MS;
        if (stale) {
            cached = registry.discover(service);
            cachedService = service;
            cachedAtMs = now;
        }
        return cached;
    }
}

Why the naive version is wrong

Three defects hide in the tempting one-liners:

(One honesty note: the cache fields here assume a single caller thread. Share a ClientService across threads and you would guard the cache — e.g. an AtomicReference to an immutable snapshot.)

Trace it with real values

Two instances of orders register at t=0: i-1 at 10.0.0.11:8080 and i-2 at 10.0.0.12:8080. i-2 crashes at t=2s and stops beating; i-1 keeps beating on schedule at t=0, 5, 10, 15. Registry TTL = 15s, client cache TTL = 5s. This is the exact output of running the code above:

t (s)EventCache?discover("orders")RR#PickedResult
0i-1, i-2 heartbeatboth leased
1client picksmiss → fetch[.12, .11]0 → idx 0.12 (i-2)OK (i-2 alive)
2i-2 crashes (silent)lease still valid
3client pickshit (age 2s)[.12, .11]1 → idx 1.11 (i-1)OK
7client picksmiss (age 6s) → fetch[.12, .11]2 → idx 0.12 (i-2)FAILS — i-2 dead, lease not yet expired
16client picksmiss → fetch[.11]3 → idx 0.11 (i-1)OK — i-2 evicted (cutoff=1s > 0)

The instructive rows are t=7 and t=16. At t=7 the client still routes to the dead i-2: its lease does not expire until t=15 (last beat at t=0 + 15s TTL), so discover honestly still lists it. The failure window is real and bounded — up to TTL + cacheTTL ≈ 20s of possibly routing to a corpse. At t=16 the cutoff finally passes i-2's last beat, removeIf reaps it, and traffic converges on the survivor. Note also that discover returned [.12, .11], not [.11, .12]: ConcurrentHashMap iteration order is unspecified, which matters for round-robin fairness (Pitfalls).

diagram
diagram

Pitfalls

When to use it — and when not

The example implements client-side discovery with self-registration: the caller queries the registry and chooses an instance itself. Reach for it when these signals line up:

Trade-offs vs the named alternatives

Do not reach for any of this when you have a fixed, small set of long-lived instances — a static config file or a single load balancer with a hand-maintained pool is simpler, has no registry to operate, and no detection-lag failure mode. Discovery earns its complexity only once addresses genuinely churn.

Takeaways


Sources: Chris Richardson, Microservices Patterns (Manning, 2018) — Client-Side Discovery, Server-Side Discovery, Self- and Third-Party Registration patterns; Sam Newman, Building Microservices (2nd ed.); the Netflix Eureka + Ribbon and HashiCorp Consul lease/health-check models; and the Kubernetes Service / endpoint-controller docs for the third-party-registration case. Code compiled and traced on OpenJDK; the displayed List.of() targets Java 9+. Re-authored / deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on Service Discovery Pattern An Example? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Service Discovery Pattern An Example** (System Design) and want to truly understand it. Explain Service Discovery Pattern An Example from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Service Discovery Pattern An Example** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Service Discovery Pattern An Example** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Service Discovery Pattern An Example** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes