The Saga Pattern: Managing Distributed Transactions Without Two-Phase Commit
The Saga pattern for managing distributed transactions. Choreography vs. orchestration, compensation strategies, and when to choose sagas over ACID.

In microservices, failure is not exceptional: it's expected. Services go down, networks partition, and databases hit capacity. Without circuit breakers, a single failing dependency can cascade through the system, consuming threads, exhausting connection pools, and taking down services that are otherwise healthy. The circuit breaker pattern prevents this cascade by failing fast when a dependency is unhealthy.
I learned this the hard way on a checkout system that called six downstream services. The recommendation service started returning 504s under load, and because we had no circuit breaker, every checkout request blocked for 30 seconds waiting for a recommendation response that would never come. The thread pool filled up within minutes and the entire checkout flow went down, even though five of the six services were perfectly healthy. A single degraded dependency took out a system that processed thousands of orders per hour.

A circuit breaker wraps calls to an external dependency and monitors failure rates. It operates in three states:
The sliding window is how the breaker tracks failure rates. Resilience4j offers two types: count-based (last N calls) and time-based (calls within the last N seconds). Count-based windows are simpler but can be misleading for low-traffic services where 100 calls might span hours. Time-based windows react to sudden spikes better, but require enough traffic within the window to produce a meaningful failure rate.
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.slidingWindowSize(100)
.minimumNumberOfCalls(20)
.permittedNumberOfCallsInHalfOpenState(10)
.build();
CircuitBreaker breaker = CircuitBreaker.of("inventory-service", config);
Supplier<InventoryResponse> decoratedCall = CircuitBreaker
.decorateSupplier(breaker, () -> inventoryClient.checkStock(skuId));The minimumNumberOfCalls setting is easy to overlook but critical. Without it, the breaker can trip after just two calls if both fail (100% failure rate). Setting a minimum of 20 calls ensures the breaker only trips when there's statistically meaningful evidence of a problem.
When the circuit is open, you need a fallback. The right strategy depends on the use case:
Try<InventoryResponse> result = Try.ofSupplier(decoratedCall)
.recover(CallNotPermittedException.class, e -> cachedInventory.get(skuId))
.recover(TimeoutException.class, e -> InventoryResponse.unknown(skuId));The fallback you choose has business implications that go beyond engineering. Returning cached inventory data means you might accept an order for an out-of-stock item. Returning a default "in stock" response is even riskier. Returning "unknown" is safer but might lose the sale. These decisions should involve product owners, not just engineers. Document the fallback behavior for each circuit breaker and make sure the business understands the tradeoffs.
Circuit breakers work best in combination with other patterns. The order in which you compose them matters. In Resilience4j, decorators execute from outermost to innermost:
Supplier<InventoryResponse> resilientCall = Decorators
.ofSupplier(() -> inventoryClient.checkStock(skuId))
.withRetry(retry)
.withCircuitBreaker(breaker)
.withBulkhead(bulkhead)
.withTimeLimiter(timeLimiter)
.decorate();This means the time limiter fires first (preventing indefinite hangs), then the bulkhead limits concurrency, then the circuit breaker checks if the dependency is healthy, and finally the retry handles transient failures. Getting this order wrong creates subtle bugs: if you put the retry outside the circuit breaker, retries will bypass the breaker entirely.
Retry transient failures, but let the circuit breaker prevent retrying a dependency that's down:
Bulkheads isolate resource pools per dependency. If the inventory service is slow, it consumes threads from the inventory pool, not from the payment or shipping pools. Circuit breakers detect the slowdown and stop sending traffic. Without bulkheads, a slow dependency can exhaust the shared thread pool and starve healthy dependencies of resources.
Always set timeouts on external calls. Without timeouts, a hanging dependency ties up threads indefinitely. The circuit breaker counts timeouts as failures:
Circuit breaker state transitions are critical operational signals:
Expose breaker state, failure counts, and success rates as metrics. Dashboard them alongside dependency latency and error rates.
breaker.getEventPublisher()
.onStateTransition(event ->
meterRegistry.counter("circuit_breaker_state_transition",
"name", event.getCircuitBreakerName(),
"from", event.getStateTransition().getFromState().name(),
"to", event.getStateTransition().getToState().name()
).increment()
);In practice, the most useful dashboard shows three things side by side: the dependency's P99 latency, the circuit breaker's failure rate, and the breaker's current state. When you can see latency climbing, the failure rate rising, and then the breaker tripping, root-cause analysis becomes straightforward.
The Saga pattern for managing distributed transactions. Choreography vs. orchestration, compensation strategies, and when to choose sagas over ACID.

Scaling contract testing across dozens of microservices. Broker workflows, CI integration, contract versioning, and sustainable testing practices.

How Pact contract testing enables consumer-driven contracts for microservices. Real-world examples, failure modes, and CI/CD best practices.
