Skip to main content
Microservices · lesson 2 of 8
Microservices beginner Lesson 2 of 8

Service Discovery: A Registry Clients Resolve Through

Hardcoded ports don't survive a restart. A tiny in-memory registry where services register their address and clients resolve it by name instead.

By SkillByExample · Published

microservices service-discovery python

localhost:8001 only works until that port changes — a registry lets a client ask for a service by name and get back whatever address is currently valid.

Register, resolve, deregister

class ServiceRegistry:
    def __init__(self):
        self._services = {}

    def register(self, name, address):
        self._services.setdefault(name, []).append(address)
        print(f"Registered {name} at {address}")

    def deregister(self, name, address):
        self._services[name].remove(address)
        print(f"Deregistered {name} at {address}")

    def resolve(self, name):
        addresses = self._services.get(name, [])
        if not addresses:
            raise LookupError(f"no instances registered for '{name}'")
        return addresses[0]

registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")
registry.register("inventory-service", "localhost:8002")

print(f"Client resolved inventory-service -> {registry.resolve('inventory-service')}")

registry.deregister("inventory-service", "localhost:8001")
print(f"Client resolved inventory-service -> {registry.resolve('inventory-service')}")

try:
    registry.resolve("payment-service")
except LookupError as e:
    print(f"Error: {e}")
$ python service_registry.py
Registered inventory-service at localhost:8001
Registered inventory-service at localhost:8002
Client resolved inventory-service -> localhost:8001
Deregistered inventory-service at localhost:8001
Client resolved inventory-service -> localhost:8002
Error: no instances registered for 'payment-service'

resolve always returns addresses[0] here, so once localhost:8001 is deregistered the same call returns localhost:8002 without the client changing a single line — the client never hardcoded a port, it only ever asked the registry for “inventory-service.” That indirection is the entire value: services can restart, move, or scale up and down, and every caller keeps working as long as it goes through the registry instead of a literal address.

Practice

1. Register three addresses for one service and round-robin across them.
class ServiceRegistry:
    def __init__(self):
        self._services = {}
        self._next_index = {}

    def register(self, name, address):
        self._services.setdefault(name, []).append(address)

    def resolve(self, name):
        addresses = self._services.get(name, [])
        if not addresses:
            raise LookupError(f"no instances registered for '{name}'")
        index = self._next_index.get(name, 0)
        address = addresses[index % len(addresses)]
        self._next_index[name] = index + 1
        return address

registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")
registry.register("inventory-service", "localhost:8002")
registry.register("inventory-service", "localhost:8003")

for i in range(5):
    print(f"request {i + 1} -> {registry.resolve('inventory-service')}")
$ python round_robin.py
request 1 -> localhost:8001
request 2 -> localhost:8002
request 3 -> localhost:8003
request 4 -> localhost:8001
request 5 -> localhost:8002

index % len(addresses) wraps back to the start once every address has had a turn — request 4 lands on localhost:8001 again. This is client-side load balancing, the same idea a real load balancer applies at the network layer instead of inside the calling code.

2. Add a list_services method that shows every registered name and its addresses.
class ServiceRegistry:
    def __init__(self):
        self._services = {}

    def register(self, name, address):
        self._services.setdefault(name, []).append(address)

    def list_services(self):
        return {name: list(addrs) for name, addrs in self._services.items()}

registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")
registry.register("payment-service", "localhost:8002")
registry.register("inventory-service", "localhost:8003")

print(registry.list_services())
$ python list_services.py
{'inventory-service': ['localhost:8001', 'localhost:8003'], 'payment-service': ['localhost:8002']}

Returning list(addrs) instead of the internal list directly means a caller who mutates the result can’t accidentally corrupt the registry’s own state.

3. Deregister an address that was never registered, and see what actually happens.
class ServiceRegistry:
    def __init__(self):
        self._services = {}

    def register(self, name, address):
        self._services.setdefault(name, []).append(address)

    def deregister(self, name, address):
        self._services[name].remove(address)

registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")

try:
    registry.deregister("inventory-service", "localhost:9999")
except ValueError as e:
    print(f"Error: {e}")
$ python bad_deregister.py
Error: list.remove(x): x not in list

That’s Python’s own list.remove error leaking straight through — the registry does no validation of its own before calling it. A production registry would catch this and raise something more specific, which is exactly what the next task does.

4. Replace the generic LookupError with a custom ServiceNotFoundError.
class ServiceNotFoundError(Exception):
    pass

class ServiceRegistry:
    def __init__(self):
        self._services = {}

    def register(self, name, address):
        self._services.setdefault(name, []).append(address)

    def resolve(self, name):
        addresses = self._services.get(name, [])
        if not addresses:
            raise ServiceNotFoundError(f"no instances registered for '{name}'")
        return addresses[0]

registry = ServiceRegistry()
registry.register("inventory-service", "localhost:8001")

try:
    registry.resolve("shipping-service")
except ServiceNotFoundError as e:
    print(f"Error: {e}")
$ python custom_exception.py
Error: no instances registered for 'shipping-service'

A caller can now catch ServiceNotFoundError specifically instead of the generic LookupError, which matters once the same codebase has other kinds of lookups that shouldn’t be confused with a missing service.

Next: what a client should do when the address it resolves stops responding — a circuit breaker that stops calling a failing service instead of retrying it forever.

Frequently Asked Questions

Isn't this just a dictionary?
At this scale, yes — and that's the point. Production service discovery (Consul, etcd, Kubernetes' own DNS-based discovery) solves the same problem — 'give me a live address for this service name' — with replication, health checks, and network calls added on top. The core contract, register-then-resolve, is exactly what's here.
Why register multiple addresses for one service name?
Because real services run more than one instance for capacity and redundancy. A registry that only stored one address per name couldn't represent that — resolving would have nothing to choose between, and Practice 1 in this lesson shows what changes once there's a real choice to make.
What replaces this in a real deployment?
In Kubernetes, a Service object does this job — Pods register by simply existing with the right labels, and DNS resolves the Service name to a live Pod's address. The `kubernetes` topic's `services` lesson covers that mechanism directly; this lesson is the stripped-down idea underneath it.
What happens if a service crashes without deregistering?
This toy registry has no way to find out — it only removes an address when `deregister` is called explicitly. Real registries solve this with heartbeats or health checks that expire an address automatically after it stops responding, which is a meaningfully harder problem than the register/resolve pair shown here.