Microservices Patterns — What Actually Works

Microservices are not a goal. They are a coping mechanism for organizational scale. Before you split anything, ask: are there teams that need to ship independently, or am I just chasing a résumé pattern? The rest of this file assumes the answer is “yes, split” — and then teaches you the patterns that keep the split from destroying you.

You will spend the rest of your career adjusting service boundaries. The concrete techniques below are how you keep the pain bounded.

Service Decomposition — DDD-Lite

Domain-Driven Design done in full is a two-year commitment. DDD-lite is the 20% that pays off in the first month. The unit of decomposition is the bounded context — a slice of the domain with its own model, its own vocabulary, and its own data.

Signal

Split

Don’t split

Different teams own it

Different scaling profile (10 QPS vs 10k QPS)

Different rate of change

Different data store fits better

Just because “it feels like a separate thing”

To avoid a database migration

⚠️ What most people get wrong: they decompose by noun (“User service”, “Product service”, “Order service”) and end up with a distributed transaction across all three on every checkout. Decompose by capability — the smallest slice of business logic that can own its data and deploy independently.

Concrete heuristic: if two services need to write to each other’s tables inside a single business operation, they are one service wearing a costume. Merge them.

API Gateway — Spring Cloud Gateway

The gateway is the front door: TLS termination, auth, rate limiting, request logging, and (sparingly) routing rewrites. Spring Cloud Gateway (reactive, Netty-based) is the current standard for Spring shops; Kong / Traefik / Envoy are the polyglot alternatives.

# application.yml — Spring Cloud Gateway
spring:
  cloud:
    gateway:
      routes:
        - id: shortly-links
          uri: http://shortly-links:8080
          predicates:
            - Path=/api/v1/links/**
          filters:
            - name: CircuitBreaker
              args:
                name: linksCB
                fallbackUri: forward:/fallback/links
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100
                redis-rate-limiter.burstCapacity: 200
        - id: shortly-analytics
          uri: http://shortly-analytics:8080
          predicates:
            - Path=/api/v1/analytics/**
          filters:
            - StripPrefix=1

Rules of thumb:

  • The gateway does cross-cutting concerns. It does NOT contain business logic. Ever.

  • Auth happens at the gateway and the service. Defense in depth. Never trust the network.

  • Prefer stateless filters. If a filter needs Redis, it’s already a smell.

Service Discovery

In Kubernetes, service discovery is just DNS (http://shortly-links.default.svc.cluster.local:8080). Do not install Eureka on top of Kubernetes — you get two discovery layers fighting each other. On plain VMs or Nomad, Consul or Eureka earns its keep.

Environment

Discovery

Kubernetes

Kubernetes DNS (native)

ECS / Cloud Run

Cloud DNS + load balancer

Bare VMs, hybrid

Consul, Nomad, Eureka

Local dev

docker-compose service names

Resilience4j — The Post-Hystrix Standard

Netflix retired Hystrix in 2018. Resilience4j is the current answer: lightweight, functional, integrates with Micrometer for metrics, and offers five decorators — circuit breaker, retry, bulkhead, rate limiter, time limiter.

Dependencies

<dependency>
  <groupId>io.github.resilience4j</groupId>
  <artifactId>resilience4j-spring-boot3</artifactId>
  <version>2.2.0</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

Config — All Four in One

resilience4j:
  circuitbreaker:
    instances:
      analyticsCB:
        registerHealthIndicator: true
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 20
        minimumNumberOfCalls: 10
        failureRateThreshold: 50           # % failures to open
        slowCallRateThreshold: 50
        slowCallDurationThreshold: 2s
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 3
        automaticTransitionFromOpenToHalfOpenEnabled: true
  retry:
    instances:
      analyticsRetry:
        maxAttempts: 3
        waitDuration: 200ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
        retryExceptions:
          - java.io.IOException
          - org.springframework.web.client.ResourceAccessException
        ignoreExceptions:
          - com.shortly.NotFoundException
  bulkhead:
    instances:
      analyticsBH:
        maxConcurrentCalls: 20
        maxWaitDuration: 100ms
  timelimiter:
    instances:
      analyticsTL:
        timeoutDuration: 3s
        cancelRunningFuture: true

Usage

@Service
class AnalyticsClient {
    private final RestClient rest;

    AnalyticsClient(RestClient.Builder b) {
        this.rest = b.baseUrl("http://shortly-analytics:8080").build();
    }

    @CircuitBreaker(name = "analyticsCB", fallbackMethod = "fallbackHits")
    @Retry(name = "analyticsRetry")
    @Bulkhead(name = "analyticsBH")
    @TimeLimiter(name = "analyticsTL")
    public CompletableFuture<Long> hitsFor(String slug) {
        return CompletableFuture.supplyAsync(() ->
            rest.get().uri("/hits/{s}", slug).retrieve().body(Long.class));
    }

    private CompletableFuture<Long> fallbackHits(String slug, Throwable ex) {
        // Log at WARN; return a safe default
        return CompletableFuture.completedFuture(-1L);
    }
}

Order of Decorators Matters

Resilience4j applies decorators in a specific order when combined: Retry CircuitBreaker RateLimiter TimeLimiter Bulkhead. Read this carefully — a retry that fires inside the circuit breaker will count as a single call, not N calls, which is usually what you want.

⚠️ What most people get wrong: they retry inside every layer (RestClient + Feign + Resilience4j + Spring Retry) and end up with 3×3×3 = 27 retries under load. Pick one place to retry. Usually: at the client boundary, with jitter, with a hard cap.

Timeouts — The Silent Killer

Every downstream call must have a timeout. Every one. The default HTTP client timeout is infinite on most JVMs, and one hung downstream will exhaust your thread pool in minutes. Sizing:

  • Total request budget = 2s (typical)

  • Direct DB call: 500ms

  • One downstream service: 1s

  • Two chained downstreams: 800ms each — or refactor to parallel

Correlation IDs — The One Header That Saves You

Every request that enters your system gets an ID (or inherits one from the caller). Propagate it on every outbound call. Log it in every log line. When something breaks, grep for the ID across services and you have the full story.

@Component
class CorrelationIdFilter extends OncePerRequestFilter {
    static final String HEADER = "X-Request-Id";
    static final String MDC_KEY = "requestId";

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        var id = Optional.ofNullable(req.getHeader(HEADER))
                         .filter(s -> !s.isBlank())
                         .orElse(UUID.randomUUID().toString());
        MDC.put(MDC_KEY, id);
        res.setHeader(HEADER, id);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.remove(MDC_KEY);
        }
    }
}

For outbound calls, add a ClientHttpRequestInterceptor that reads MDC.get("requestId") and sets the same header. OpenTelemetry’s traceId gives you distributed context for free, but X-Request-Id remains valuable as a user-facing / customer-support handle.

Idempotency Keys — Non-Negotiable for POST

Any state-changing endpoint that could be retried (by the client, by a gateway, by a proxy) must be idempotent. The standard pattern is Stripe’s Idempotency-Key header:

  1. Client generates a UUID per logical operation and sends it as Idempotency-Key: <uuid>.

  2. Server checks Redis for idem:{key}. If present, returns the cached response.

  3. If absent, executes the operation, stores the response under idem:{key} with a 24-hour TTL, and returns it.

  4. Concurrent requests with the same key are serialized (Redis SET NX PX acts as a lock).

@RestController
@RequiredArgsConstructor
class LinkController {
    private final LinkService links;
    private final StringRedisTemplate redis;
    private final ObjectMapper json;

    @PostMapping("/api/v1/links")
    ResponseEntity<LinkResponse> create(
            @RequestHeader(value = "Idempotency-Key", required = false) String key,
            @Valid @RequestBody CreateLinkRequest req,
            @AuthenticationPrincipal Jwt jwt) throws Exception {
        if (key != null) {
            var cached = redis.opsForValue().get("idem:" + key);
            if (cached != null) {
                return ResponseEntity.ok(json.readValue(cached, LinkResponse.class));
            }
        }
        var res = links.create(req, jwt.getSubject());
        if (key != null) {
            redis.opsForValue().set("idem:" + key, json.writeValueAsString(res), Duration.ofHours(24));
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(res);
    }
}

Production hardens this further (lock the key while executing, hash the request body to detect key reuse with different payloads). For an study, showing the basic pattern is enough.

Saga vs 2PC — Distributed Transactions

Two-phase commit across microservices is dead. It doesn’t scale, it blocks under partial failure, and no cloud database supports it across services. The alternative is the saga pattern: a sequence of local transactions, each with a compensating action.

Style

How

Pain

Choreography

Services emit events; other services react

Hard to trace, no single owner

Orchestration

Central saga coordinator (e.g., Camunda, Temporal, Axon) sequences the steps

Coordinator is a single point of failure — HA it

For 90% of study scenarios, describe the orchestration pattern with Temporal or a simple state-machine service. If you need it, you’ll know.

Anti-Patterns Checklist

  • Shared database across services (the “distributed monolith”)

  • Synchronous chains > 3 deep (any one failure kills the whole request)

  • “We’ll add circuit breakers later”

  • Idempotency added only to payment endpoints

  • Every service using its own JSON schema for the same domain entity

  • No correlation ID

  • Retry storms because retries stack across layers

If you tick two of these, you have a distributed monolith, not microservices. Refactor before adding features.

Practice

  1. Take a monolithic e-commerce app spec (cart, checkout, inventory, payment, notification). Draw a bounded-context diagram, justify each seam, mark the sagas.

  2. Add Resilience4j to shortly (Phase 07). Simulate a downstream failure with Toxiproxy and confirm the circuit breaker opens.

  3. Implement Idempotency-Key on POST /links. Write a test that fires the same request 100 times and asserts exactly one row created.


Return to README.md · Previous: ../07_enterprise_spring_data/projects.md · Next: 02_event_driven_kafka.md