Part 2 – The Architecture: Services and Event Brokers
Welcome to Part 2 of our series on the SAGA Pattern! In Part 1 we clarified the theoretical basis of why we need sagas in microservice architectures and learned the difference between choreography and orchestration.
Today it’s time to put it into practice. We are implementing a choreography-based Saga using Spring Boot and RabbitMQ. The goal is to make the ordering process (order, payment, inventory) resilient and “eventually consistent”.
Unser Event-Setup mit RabbitMQ
The heart of a choreography saga is the message broker. It decouples our services. Instead of the Order Service having to call the Payment Service directly via REST (which would lead to tight coupling), it simply sends an event “out into the world”.
We use RabbitMQ and define a central Topic Exchange for our saga. A topic exchange is ideal because it allows us flexible routing based on “routing keys” (patterns).
Unser Exchange: saga_exchange
The “happy path” (everything works) of our saga is controlled via the following events (and routing keys):
- Client ->
Order Service(REST API): A customer initiates an order. Order Service:- Creates the order in its local DB (status:
PENDING). - Sends an
OrderCreatedEventto thesaga_exchangewith the routing keyorder.created.
- Creates the order in its local DB (status:
Payment Service:- Listens for events with the key
order.created. - Processes payment.
- Sends a
PaymentSuccessfulEventto thesaga_exchangewith the keypayment.successful.
- Listens for events with the key
Inventory Service:- Listens for events with the key
payment.successful. - Reserves the goods in the warehouse.
- Sends an
InventorySuccessfulEventto thesaga_exchangewith the keyinventory.successful.
- Listens for events with the key
Order Service(optional but recommended):- Listens to
inventory.successful. - Updates the order status in its DB to
COMPLETED.
- Listens to
The Spring Boot configuration (example: Payment Service)
In order for this event flow to work, we need to configure Spring Boot and RabbitMQ (via spring-boot-starter-amqp). Each service defines the queues it needs and binds them to the central exchange.
Here is an example of what the configuration in Payment Service might look like. It needs to listen for order.created:
// In einer @Configuration Klasse im Payment Service
@Bean
public TopicExchange sagaExchange() {
// Definiert den Exchange (idempotent)
return new TopicExchange("saga_exchange");
}
@Bean
public Queue paymentQueue() {
// Definiert die Queue, in der dieser Service lauscht
return new Queue("payment_queue");
}
@Bean
public Binding paymentBinding(Queue paymentQueue, TopicExchange sagaExchange) {
// Wir binden die 'payment_queue' an den 'saga_exchange'
// und lauschen auf den Routing Key 'order.created'
return BindingBuilder.bind(paymentQueue).to(sagaExchange).with("order.created");
}
// ... eventuell weitere Bindings für Kompensationsevents ...
The code for sending events (e.g. PaymentSuccessfulEvent) is also straightforward. We inject Spring’s RabbitTemplate:
// Im PaymentService, nachdem die Zahlung erfolgreich war
@Autowired
private RabbitTemplate rabbitTemplate;
public void processPayment(OrderCreatedEvent event) {
// ... Logik zur Zahlungsabwicklung ...
if (paymentSuccessful) {
PaymentSuccessfulEvent successEvent = new PaymentSuccessfulEvent(/* ... */);
// Sende das Erfolgs-Event an den Exchange
rabbitTemplate.convertAndSend("saga_exchange", "payment.successful", successEvent);
} else {
// ... Fehlerbehandlung (dazu später mehr)
}
}
And to listen for the event, we use the @RabbitListener annotation:
// Im PaymentService
@RabbitListener(queues = "payment_queue")
public void handleOrderCreated(OrderCreatedEvent event) {
System.out.println("Received OrderCreatedEvent: " + event.getOrderId());
processPayment(event);
}
The “Unhappy Path” – compensation via event
What happens if one of our steps fails? Let’s take the most common scenario: the Order Service creates the order (status PENDING), sends the OrderCreatedEvent, but the Payment Service cannot authorize the payment (e.g. lack of funds).
Now the saga must be unwound.
Payment Service(Error Case):
- Payment logic fails.
- Instead of a
PaymentSuccessfulEvent, the service now sends aPaymentFailedEventto thesaga_exchangewith the routing keypayment.failed.
- Instead of a
- `Order Service’ (Compensation):
- The
Order Servicenow has to compensate for its original action (creating the order).- Listens for the
payment.failedevent. - When he receives it, he changes the status of the order in his local database from
PENDINGtoCANCELLED.
- Listens for the
The system is then possibly consistent again: there is a canceled order and no payment.
Compensation configuration
To do this, the Order Service must define an additional queue and a binding for the error events:
// In einer @Configuration Klasse im Order Service
// ... (Exchange und 'order_queue' etc. von vorher) ...
@Bean
public Queue orderCompensationQueue() {
// Eine dedizierte Queue für Kompensations-Events, die diesen Service betreffen
return new Queue("order_compensation_queue");
}
@Bean
public Binding paymentFailedBinding(Queue orderCompensationQueue, TopicExchange sagaExchange) {
// Wir binden die Kompensations-Queue an den Exchange
// und lauschen auf den Routing Key 'payment.failed'
return BindingBuilder.bind(orderCompensationQueue).to(sagaExchange).with("payment.failed");
}
Sending the error event (in the payment service)
If processPayment fails, we send the corresponding event:
// Im PaymentService
public void processPayment(OrderCreatedEvent event) {
// ... Logik zur Zahlungsabwicklung ...
if (paymentSuccessful) {
// ... (Happy Path)
} else {
// UNHAPPY PATH
PaymentFailedEvent failureEvent = new PaymentFailedEvent(event.getOrderId(), "Insufficient funds");
// Sende das Fehler-Event an den Exchange
rabbitTemplate.convertAndSend("saga_exchange", "payment.failed", failureEvent);
}
}
Receiving the error event (in the Order Service)
The Order Service intercepts this event and carries out the compensation (cancellation):
// Im OrderService
@RabbitListener(queues = "order_compensation_queue")
public void handlePaymentFailed(PaymentFailedEvent event) {
System.out.println("Compensation: Received PaymentFailedEvent for Order: " + event.getOrderId());
// Finde die Bestellung
Order order = orderRepository.findById(event.getOrderId()).orElse(null);
if (order != null && order.getStatus() == OrderStatus.PENDING) {
// Führe die kompensierende Transaktion aus
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
System.out.println("Order " + event.getOrderId() + " compensated (CANCELLED).");
} else {
// Wichtig: Logik idempotent halten!
// Falls das Event mehrfach kommt oder die Bestellung schon storniert ist.
}
}
The pitfalls of choreography
This approach works, but you may already see the challenge:
- Distributed business logic: The
Order Servicenow needs to know to respond topayment.failed. - Cyclic dependencies: What happens if the compensation itself fails? (e.g. DB error when setting to
CANCELLED). This requires complex retry mechanisms and dead letter queues. - Monitoring: It is very difficult to keep track of the overall status of a saga. If an event is lost or a service is down, the saga gets “stuck” and it’s hard to debug where exactly it is stuck.
For a 3-step saga, this is manageable. With 5 or more steps, the number of event links (who listens to whom, who compensates for whom) quickly becomes exponentially complex.
Understood. The choreography implementation is therefore completely covered. We talked through the “Happy Path” and the “Unhappy Path” with compensation.
Here is the draft conclusion of Part 2, summarizing the pros and cons of this approach.
Conclusion Part 2 – The strengths and weaknesses of the choreography
We have implemented a complete choreography based saga in this article. With Spring Boot and RabbitMQ we managed to loosely couple three microservices (Order, Payment, Inventory).
The key advantage of this approach is decentralization:
- Loose coupling: No service knows the other services. You just need to know the central
saga_exchangeand the event structures. Adding new services (e.g. aShipping Servicethat listens toInventorySuccessfulEvent) is easy without having to change existing services. - Independence: Each service is autonomous and only responsible for its own logic and compensation.
However, we also saw the pitfalls of the choreography:
- Distributed business logic: The overall process flow is not centrally defined anywhere. To understand the saga, you have to look at how all services interact via events. This is often referred to as “knowing too much” - the order service needs to know that a payment.failed event requires a cancellation on its part.
- Difficult monitoring: It is extremely difficult to determine what state a particular saga (e.g. for
OrderID-123) is in. Is she making payments? Is she waiting for inventory? Did it fail but the compensation is stuck? - Complex error handling: If a compensating transaction itself fails (e.g. the
Order Serviceis down while thePaymentFailedEventarrives), we end up in a difficult-to-resolve state that requires manual intervention or complex retry mechanisms (dead letter queues).
Conclusion: The choreography is elegant for simple sagas with few participants. However, as complexity increases, the lack of central control can result in a system that is difficult to maintain.
This is exactly where the alternative approach comes in: orchestration, in which a central coordinator holds the reins. But that is a topic for a future article.
![[EN] SAGA Pattern in der Praxis: Choreografie mit Spring Boot & RabbitMQ (Teil 2)](/images/SAGA-Choreographie-BlogHeader.png)