Java Microservices — Part 3 of 4
Java Microservices: Data &
Resilience
Database-per-service, the saga pattern, and circuit breakers
A practical guide for engineers moving from monolithic Spring Boot applications to a microservices
architecture, written for hands-on project work.
1. Database Per Service
In a monolith, every module reaches into the same database and can join across tables freely. In
microservices, each service owns its own database — and nothing else is allowed to touch it directly.
Other services only see that data through the owning service's API.
Figure 1.1 — Each service persists to its own database; no service reaches directly into another service's tables.
This is the single biggest mental shift coming from a monolith. It buys you independent schema evolution
and true service isolation, but it means you give up cross-service SQL joins and ACID transactions that
used to be trivial. Two consequences follow directly: you need a strategy for keeping data consistent
across services (the saga pattern, below), and you need a strategy for querying data that spans services
(typically a read-optimized view built from events, sometimes called CQRS — out of scope here, but worth
knowing the name for when you hit the need).
2. Keeping Data Consistent: The Saga Pattern
Placing an order might need to: create the order, reserve inventory, and charge the customer — three
services, three separate databases, no single transaction that spans all of them. A saga breaks this into a
sequence of local transactions, one per service, where each step publishes an event that triggers the next
step. If a step fails, previously completed steps are undone with compensating transactions rather than
a database rollback.
Figure 2.1 — A choreography-based saga: each service reacts to the previous service's event and, on failure,
compensating events unwind completed steps.
2.1 Example: compensating a failed payment
@KafkaListener(topics = "payment-failed", groupId = "inventory-service")
public void handlePaymentFailed(PaymentFailedEvent event) {
// Compensating transaction: release the stock we reserved earlier
[Link]([Link]());
}
@KafkaListener(topics = "payment-failed", groupId = "order-service")
public void handlePaymentFailedForOrder(PaymentFailedEvent event) {
[Link]([Link]());
}
Two flavors of saga exist: choreography, shown above, where services react to each other's events with
no central coordinator, and orchestration, where a dedicated saga orchestrator explicitly tells each
service what to do next. Choreography is simpler to start with; orchestration becomes worth it once a
workflow has enough steps that tracing the event chain by hand gets hard.
3. Resilience: Circuit Breakers and Retries
Network calls fail. In a monolith, a slow database query slows down one request. In microservices, a slow
or failing downstream service can exhaust threads in every service that calls it, cascading a single failure
across the whole system. Resilience4j's circuit breaker prevents this by tracking failures and, past a
threshold, failing fast instead of waiting on a service that's already struggling.
Figure 3.1 — A circuit breaker's three states: CLOSED (normal), OPEN (fail fast without calling downstream), and
HALF-OPEN (cautiously testing recovery).
3.1 Applying a circuit breaker
@Service
public class InventoryClient {
private final RestClient restClient;
@CircuitBreaker(name = "inventoryService", fallbackMethod = "stockFallback")
@Retry(name = "inventoryService")
public StockLevel getStock(String productId) {
return [Link]()
.uri("/api/stock/{productId}", productId)
.retrieve()
.body([Link]);
}
private StockLevel stockFallback(String productId, Throwable t) {
return [Link](productId);
}
}
3.2 Configuration
resilience4j:
circuitbreaker:
instances:
inventoryService:
sliding-window-size: 20
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
retry:
instances:
inventoryService:
max-attempts: 3
wait-duration: 500ms
A useful habit: every outgoing call to another service should have a timeout, a retry policy, and a circuit
breaker with a sane fallback. Skipping this is the most common cause of a small hiccup in one service
turning into a full outage across all of them.
What's next
Part 4 covers running these services for real: containerizing them with Docker, deploying to Kubernetes,
and getting visibility into a distributed system with centralized logging, metrics, and tracing.