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 |
|
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:
Client generates a UUID per logical operation and sends it as
Idempotency-Key: <uuid>.Server checks Redis for
idem:{key}. If present, returns the cached response.If absent, executes the operation, stores the response under
idem:{key}with a 24-hour TTL, and returns it.Concurrent requests with the same key are serialized (Redis
SET NX PXacts 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
paymentendpointsEvery 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¶
Take a monolithic e-commerce app spec (cart, checkout, inventory, payment, notification). Draw a bounded-context diagram, justify each seam, mark the sagas.
Add Resilience4j to
shortly(Phase 07). Simulate a downstream failure with Toxiproxy and confirm the circuit breaker opens.Implement
Idempotency-KeyonPOST /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