# Service Discovery

## Problem

In a dynamic microservice environment, services are deployed across multiple hosts, scaled up/down, and replaced during rolling updates. Hardcoding service addresses is fragile — any topology change breaks callers. You need a way for services to find each other dynamically.

## Solution

Implement a **Service Discovery** mechanism with three components:

1. **Registry** — A central (or distributed) store of service instances and their network locations.
2. **Registration** — Services register themselves on startup and deregister on shutdown (self-registration) or are registered by the deployment platform (third-party registration).
3. **Lookup** — Callers query the registry to find healthy instances of a service, optionally with load balancing.

Two styles:
- **Client-side discovery**: The client queries the registry and picks an instance.
- **Server-side discovery**: A load balancer or router queries the registry on behalf of the client.

## When to Use

- Services are dynamically scaled or deployed across changing infrastructure.
- You need load balancing across multiple instances of a service.
- The deployment platform doesn't already provide service discovery (e.g., Kubernetes DNS).

## When to Avoid

- Single-service deployments with static addresses.
- Platforms with built-in discovery (Kubernetes services, AWS ECS service discovery).
- Simple setups where a config file with addresses suffices.

## Pseudocode

```
class ServiceRegistry:
    instances = {}  // service_name -> [{id, host, port, healthy}]

    function register(name, host, port):
        instances[name].add({id: generate_id(), host, port, healthy: true})

    function deregister(name, id):
        instances[name].remove(id)

    function discover(name) -> instance:
        healthy = instances[name].filter(i => i.healthy)
        return load_balance(healthy)  // round-robin, random, etc.

    function heartbeat(name, id):
        instances[name][id].last_seen = now()
```

## Python

```python
"""Service Discovery pattern in Python."""
import time
import random
from dataclasses import dataclass, field


# ── Service instance ──────────────────────────────────────────

@dataclass
class ServiceInstance:
    service_name: str
    instance_id: str
    host: str
    port: int
    healthy: bool = True
    last_heartbeat: float = field(default_factory=time.time)
    metadata: dict = field(default_factory=dict)


# ── Service Registry ──────────────────────────────────────────

class ServiceRegistry:
    def __init__(self, heartbeat_ttl: float = 30.0):
        self._instances: dict[str, dict[str, ServiceInstance]] = {}
        self._heartbeat_ttl = heartbeat_ttl
        self._counter: int = 0  # For round-robin

    def register(self, name: str, host: str, port: int,
                 metadata: dict | None = None) -> str:
        instance_id = f"{name}-{host}:{port}"
        instance = ServiceInstance(
            service_name=name,
            instance_id=instance_id,
            host=host,
            port=port,
            metadata=metadata or {},
        )
        self._instances.setdefault(name, {})[instance_id] = instance
        return instance_id

    def deregister(self, name: str, instance_id: str) -> bool:
        instances = self._instances.get(name, {})
        if instance_id in instances:
            del instances[instance_id]
            return True
        return False

    def heartbeat(self, name: str, instance_id: str) -> bool:
        instances = self._instances.get(name, {})
        if instance_id in instances:
            instances[instance_id].last_heartbeat = time.time()
            instances[instance_id].healthy = True
            return True
        return False

    def get_healthy(self, name: str) -> list[ServiceInstance]:
        instances = self._instances.get(name, {})
        now = time.time()
        healthy = []
        for inst in instances.values():
            if now - inst.last_heartbeat > self._heartbeat_ttl:
                inst.healthy = False
            if inst.healthy:
                healthy.append(inst)
        return healthy

    def discover(self, name: str) -> ServiceInstance | None:
        """Round-robin load balancing across healthy instances."""
        healthy = self.get_healthy(name)
        if not healthy:
            return None
        self._counter += 1
        return healthy[self._counter % len(healthy)]

    def all_services(self) -> dict[str, int]:
        return {name: len(insts) for name, insts in self._instances.items()}


# ── Tests ─────────────────────────────────────────────────────

def test_register_and_discover():
    registry = ServiceRegistry()
    registry.register("user-service", "10.0.0.1", 8001)
    registry.register("user-service", "10.0.0.2", 8001)

    inst = registry.discover("user-service")
    assert inst is not None
    print(f"PASS: discovered {inst.instance_id} at {inst.host}:{inst.port}")


def test_round_robin():
    registry = ServiceRegistry()
    registry.register("api", "host-a", 80)
    registry.register("api", "host-b", 80)

    hosts = set()
    for _ in range(10):
        inst = registry.discover("api")
        hosts.add(inst.host)

    assert hosts == {"host-a", "host-b"}
    print(f"PASS: round-robin distributed to: {hosts}")


def test_deregister():
    registry = ServiceRegistry()
    iid = registry.register("worker", "10.0.0.5", 9000)
    assert registry.discover("worker") is not None

    registry.deregister("worker", iid)
    assert registry.discover("worker") is None
    print("PASS: deregistered service no longer discoverable")


def test_heartbeat_expiry():
    registry = ServiceRegistry(heartbeat_ttl=0.0)  # instant expiry
    registry.register("cache", "10.0.0.3", 6379)

    # Without heartbeat, instance is stale
    time.sleep(0.01)
    assert registry.discover("cache") is None
    print("PASS: expired heartbeat marks instance unhealthy")


def test_unknown_service_returns_none():
    registry = ServiceRegistry()
    assert registry.discover("nonexistent") is None
    print("PASS: unknown service returns None")


def test_all_services():
    registry = ServiceRegistry()
    registry.register("svc-a", "h1", 80)
    registry.register("svc-a", "h2", 80)
    registry.register("svc-b", "h3", 90)
    services = registry.all_services()
    assert services == {"svc-a": 2, "svc-b": 1}
    print(f"PASS: all services: {services}")


if __name__ == "__main__":
    test_register_and_discover()
    test_round_robin()
    test_deregister()
    test_heartbeat_expiry()
    test_unknown_service_returns_none()
    test_all_services()
    print("\nAll Service Discovery tests passed.")
```

## Go

```go
// service_discovery.go
package main

import (
	"fmt"
	"sync"
	"time"
)

// ── Service instance ─────────────────────────────────────────

type ServiceInstance struct {
	ServiceName   string
	InstanceID    string
	Host          string
	Port          int
	Healthy       bool
	LastHeartbeat time.Time
}

// ── Service Registry ─────────────────────────────────────────

type ServiceRegistry struct {
	mu           sync.RWMutex
	instances    map[string]map[string]*ServiceInstance
	heartbeatTTL time.Duration
	counter      int
}

func NewRegistry(ttl time.Duration) *ServiceRegistry {
	return &ServiceRegistry{
		instances:    make(map[string]map[string]*ServiceInstance),
		heartbeatTTL: ttl,
	}
}

func (r *ServiceRegistry) Register(name, host string, port int) string {
	r.mu.Lock()
	defer r.mu.Unlock()

	id := fmt.Sprintf("%s-%s:%d", name, host, port)
	if r.instances[name] == nil {
		r.instances[name] = make(map[string]*ServiceInstance)
	}
	r.instances[name][id] = &ServiceInstance{
		ServiceName:   name,
		InstanceID:    id,
		Host:          host,
		Port:          port,
		Healthy:       true,
		LastHeartbeat: time.Now(),
	}
	return id
}

func (r *ServiceRegistry) Deregister(name, id string) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	if insts, ok := r.instances[name]; ok {
		if _, exists := insts[id]; exists {
			delete(insts, id)
			return true
		}
	}
	return false
}

func (r *ServiceRegistry) Heartbeat(name, id string) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	if insts, ok := r.instances[name]; ok {
		if inst, exists := insts[id]; exists {
			inst.LastHeartbeat = time.Now()
			inst.Healthy = true
			return true
		}
	}
	return false
}

func (r *ServiceRegistry) GetHealthy(name string) []*ServiceInstance {
	r.mu.RLock()
	defer r.mu.RUnlock()

	var healthy []*ServiceInstance
	now := time.Now()
	for _, inst := range r.instances[name] {
		if now.Sub(inst.LastHeartbeat) > r.heartbeatTTL {
			inst.Healthy = false
		}
		if inst.Healthy {
			healthy = append(healthy, inst)
		}
	}
	return healthy
}

func (r *ServiceRegistry) Discover(name string) *ServiceInstance {
	healthy := r.GetHealthy(name)
	if len(healthy) == 0 {
		return nil
	}
	r.mu.Lock()
	r.counter++
	idx := r.counter % len(healthy)
	r.mu.Unlock()
	return healthy[idx]
}

// ── Tests ────────────────────────────────────────────────────

func testRegisterAndDiscover() {
	reg := NewRegistry(30 * time.Second)
	reg.Register("user-svc", "10.0.0.1", 8001)
	reg.Register("user-svc", "10.0.0.2", 8001)

	inst := reg.Discover("user-svc")
	if inst == nil {
		panic("expected instance")
	}
	fmt.Printf("PASS: discovered %s at %s:%d\n", inst.InstanceID, inst.Host, inst.Port)
}

func testDeregister() {
	reg := NewRegistry(30 * time.Second)
	id := reg.Register("worker", "10.0.0.5", 9000)
	if reg.Discover("worker") == nil {
		panic("should find worker")
	}
	reg.Deregister("worker", id)
	if reg.Discover("worker") != nil {
		panic("should not find deregistered worker")
	}
	fmt.Println("PASS: deregistered service no longer discoverable")
}

func testHeartbeatExpiry() {
	reg := NewRegistry(0) // instant expiry
	reg.Register("cache", "10.0.0.3", 6379)
	time.Sleep(1 * time.Millisecond)

	if reg.Discover("cache") != nil {
		panic("expired instance should not be discoverable")
	}
	fmt.Println("PASS: expired heartbeat marks instance unhealthy")
}

func testUnknownService() {
	reg := NewRegistry(30 * time.Second)
	if reg.Discover("nonexistent") != nil {
		panic("unknown service should return nil")
	}
	fmt.Println("PASS: unknown service returns nil")
}

func main() {
	testRegisterAndDiscover()
	testDeregister()
	testHeartbeatExpiry()
	testUnknownService()
	fmt.Println("\nAll Service Discovery tests passed.")
}
```

## Related Patterns

- **Proxy** — a service discovery-aware proxy (client-side load balancer) queries the registry and routes requests.
- **Circuit Breaker** — circuit breakers are applied per discovered instance; unhealthy instances are removed from the registry.
- **API Gateway** — the API gateway uses service discovery to resolve backend service addresses dynamically.
