Saga pattern with Kafka for distributed transactions: tour booking example
1. Overview
When a business process spans multiple microservices, each with its own database, you cannot wrap everything in @Transactional. The Saga pattern solves this problem. Instead of one large transaction, you run a sequence of local transactions. Each step has a compensation action to undo if a subsequent step fails.
In this article, you’ll set up an orchestration-style saga using Spring Boot 3 and Kafka for a tour booking business process. After reading, you’ll have an orchestrator coordinating the steps, participant services responding via Kafka, and test coverage for the compensation flow.
2. The tour booking problem
A tour booking consists of three steps, each belonging to a service:
- FlightService: reserves a flight seat.
- HotelService: reserves a hotel room.
- PaymentService: charges the customer.
Suppose the seat and room are reserved, but payment fails due to insufficient card balance. The system must then cancel the room and then cancel the flight. Otherwise, the customer occupies the space without paying.
Two-phase commit (2PC) can solve this, but it locks resources throughout the transaction. It also requires every service to support XA, and Kafka cannot participate in XA. Saga accepts eventual consistency: data may temporarily diverge, but will eventually become consistent.
3. Choreography vs Orchestration
Saga has two organizational patterns:
| Criterion | Choreography | Orchestration |
|---|---|---|
| Coordination | Each service listens to events from other services | One orchestrator sends commands, receives replies |
| Business flow | Scattered across multiple services | Centralized in one place |
| Suitable for | 2–3 steps, infrequent changes | Many steps, complex compensation logic |
| Risk | Circular dependencies, hard to debug | Orchestrator becomes a logic bottleneck |
With three steps and one reverse compensation flow, orchestration is easier to read and test. This article uses that approach. Kafka topics are organized as follows:
tour.commands.flight ← orchestrator sends commands to FlightService
tour.commands.hotel ← orchestrator sends commands to HotelService
tour.commands.payment ← orchestrator sends commands to PaymentService
tour.replies ← all services send results back to orchestrator
4. Project setup
4.1. Dependencies
You need to add Spring for Apache Kafka. Spring Boot manages the version for you:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
4.2. Configuration
Messages are sent as JSON. Configure serializers and deserializers as follows:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
consumer:
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: com.nonla.tour
JsonSerializer writes the class name to the __TypeId__ header. On the consumer side, JsonDeserializer reads this header to know which object to instantiate. trusted.packages restricts which packages can be deserialized, preventing security vulnerabilities.
Next, declare the topics:
@Configuration
public class KafkaTopicConfig {
@Bean
KafkaAdmin.NewTopics sagaTopics() {
return new KafkaAdmin.NewTopics(
TopicBuilder.name("tour.commands.flight").partitions(3).build(),
TopicBuilder.name("tour.commands.hotel").partitions(3).build(),
TopicBuilder.name("tour.commands.payment").partitions(3).build(),
TopicBuilder.name("tour.replies").partitions(3).build());
}
}
5. Message model
Use Java 21 records to represent commands and replies:
package com.nonla.tour;
public enum Step { FLIGHT, HOTEL, PAYMENT }
public enum Action { RESERVE, CANCEL }
public record SagaCommand(String bookingId, Step step, Action action, BigDecimal amount) {}
public record SagaReply(String bookingId, Step step, Action action,
boolean success, String reason) {}
Each message carries bookingId. This value is both the saga identifier and the message key when sent to Kafka. This way, all messages for the same booking fall into the same partition and are processed in order.
6. Defining the saga as a state machine
The most important part is the logic that decides the next step. You should separate this logic from Kafka so you can test it with plain unit tests.
public enum SagaStatus { RUNNING, COMPENSATING, COMPLETED, CANCELLED }
public class SagaState {
private final String bookingId;
private final BigDecimal amount;
private final Deque<Step> completed = new ArrayDeque<>();
private SagaStatus status = SagaStatus.RUNNING;
public SagaState(String bookingId, BigDecimal amount) {
this.bookingId = bookingId;
this.amount = amount;
}
public String bookingId() { return bookingId; }
public BigDecimal amount() { return amount; }
public Deque<Step> completed() { return completed; }
public SagaStatus status() { return status; }
public void status(SagaStatus status) { this.status = status; }
}
completed is a stack holding successfully completed steps. When compensating, you pop from the top of the stack, so steps are undone in reverse order.
public class TourBookingSaga {
static final List<Step> STEPS = List.of(Step.FLIGHT, Step.HOTEL, Step.PAYMENT);
public Optional<SagaCommand> start(SagaState s) {
return Optional.of(command(s, STEPS.getFirst(), Action.RESERVE));
}
public Optional<SagaCommand> onReply(SagaState s, SagaReply r) {
if (s.status() == SagaStatus.COMPLETED || s.status() == SagaStatus.CANCELLED) {
return Optional.empty(); // saga already finished, ignore duplicate reply
}
if (r.action() == Action.RESERVE && r.success()) {
s.completed().push(r.step());
int idx = STEPS.indexOf(r.step());
if (idx == STEPS.size() - 1) {
s.status(SagaStatus.COMPLETED);
return Optional.empty();
}
return Optional.of(command(s, STEPS.get(idx + 1), Action.RESERVE));
}
if (r.action() == Action.CANCEL && !r.success()) {
return Optional.of(command(s, r.step(), Action.CANCEL)); // retry cancellation
}
s.status(SagaStatus.COMPENSATING);
return compensateNext(s);
}
private Optional<SagaCommand> compensateNext(SagaState s) {
if (s.completed().isEmpty()) {
s.status(SagaStatus.CANCELLED);
return Optional.empty();
}
return Optional.of(command(s, s.completed().pop(), Action.CANCEL));
}
private SagaCommand command(SagaState s, Step step, Action action) {
return new SagaCommand(s.bookingId(), step, action, s.amount());
}
}
The onReply function handles four cases:
- RESERVE succeeds: record this step to the stack. If this is the last step, move saga to
COMPLETED. Otherwise, send a RESERVE command for the next step. - RESERVE fails: move saga to
COMPENSATINGand start undoing the most recently completed step. - CANCEL succeeds: undo the previous step. When the stack is empty, move saga to
CANCELLED. - CANCEL fails: resend the cancellation command. Compensation must not be abandoned.
Note that the failed step does not go into the stack. If payment fails, there is nothing to refund, so the saga only needs to cancel the room and flight.
7. Orchestrator
The orchestrator does two things: send commands to Kafka and listen for replies from tour.replies.
@Service
public class TourBookingOrchestrator {
private static final Logger log = LoggerFactory.getLogger(TourBookingOrchestrator.class);
private final KafkaTemplate<String, Object> kafka;
private final TourBookingSaga saga = new TourBookingSaga();
private final Map<String, SagaState> store = new ConcurrentHashMap<>();
public TourBookingOrchestrator(KafkaTemplate<String, Object> kafka) {
this.kafka = kafka;
}
public void book(String bookingId, BigDecimal amount) {
SagaState state = new SagaState(bookingId, amount);
store.put(bookingId, state);
saga.start(state).ifPresent(this::send);
}
@KafkaListener(topics = "tour.replies", groupId = "tour-orchestrator")
public void onReply(SagaReply reply) {
SagaState state = store.get(reply.bookingId());
if (state == null) {
return;
}
saga.onReply(state, reply).ifPresent(this::send);
log.info("booking={} reply={}/{} ok={} -> status={}",
reply.bookingId(), reply.step(), reply.action(), reply.success(), state.status());
}
private void send(SagaCommand cmd) {
String topic = "tour.commands." + cmd.step().name().toLowerCase();
kafka.send(topic, cmd.bookingId(), cmd);
}
public SagaStatus statusOf(String bookingId) {
return store.get(bookingId).status();
}
}
send() uses bookingId as the key. The topic name is derived from Step, e.g., HOTEL becomes tour.commands.hotel.
Note:
ConcurrentHashMapis only for demo. In production, saga state must be persisted to a database. If the orchestrator restarts mid-saga, it needs to read the state to resume or compensate.
8. Participant service
Each participant listens to its command topic, executes a local transaction, then sends a reply. For example, with HotelService:
@Component
public class HotelCommandHandler {
private final KafkaTemplate<String, Object> kafka;
private final Set<String> reservedRooms = ConcurrentHashMap.newKeySet();
private final AtomicInteger availableRooms = new AtomicInteger(10);
public HotelCommandHandler(KafkaTemplate<String, Object> kafka) {
this.kafka = kafka;
}
@KafkaListener(topics = "tour.commands.hotel", groupId = "hotel-service")
public void handle(SagaCommand cmd) {
boolean ok = switch (cmd.action()) {
case RESERVE -> reserve(cmd.bookingId());
case CANCEL -> cancel(cmd.bookingId());
};
kafka.send("tour.replies", cmd.bookingId(),
new SagaReply(cmd.bookingId(), Step.HOTEL, cmd.action(), ok, ok ? null : "No rooms available"));
}
private boolean reserve(String bookingId) {
if (reservedRooms.contains(bookingId)) {
return true; // already reserved, message was resent
}
if (availableRooms.getAndUpdate(n -> n > 0 ? n - 1 : n) == 0) {
return false;
}
reservedRooms.add(bookingId);
return true;
}
private boolean cancel(String bookingId) {
if (reservedRooms.remove(bookingId)) {
availableRooms.incrementAndGet();
}
return true; // cancelling a never-reserved booking still succeeds
}
}
Two important details in this code:
reserve()is idempotent. Kafka guarantees at-least-once delivery, so the same command may arrive twice. If a room is already reserved for thisbookingId, the handler returns success without decrementing the room count again.cancel()is also idempotent and always succeeds. A cancellation command may arrive before a reservation command, or for a booking that never reserved a room. Neither case should corrupt the data.
FlightCommandHandler and PaymentCommandHandler have similar structure. With Payment, CANCEL means refund.
When PaymentService rejects payment, the orchestrator log looks like:
booking=BK-1001 reply=FLIGHT/RESERVE ok=true -> status=RUNNING
booking=BK-1001 reply=HOTEL/RESERVE ok=true -> status=RUNNING
booking=BK-1001 reply=PAYMENT/RESERVE ok=false -> status=COMPENSATING
booking=BK-1001 reply=HOTEL/CANCEL ok=true -> status=COMPENSATING
booking=BK-1001 reply=FLIGHT/CANCEL ok=true -> status=CANCELLED
The room is cancelled first, the flight is cancelled second, in reverse order of reservation.
9. Testing the saga flow
Since TourBookingSaga doesn’t depend on Kafka, you test it with JUnit 5 and AssertJ without needing a broker:
class TourBookingSagaTest {
private final TourBookingSaga saga = new TourBookingSaga();
private SagaReply ok(Step step, Action action) {
return new SagaReply("BK-1", step, action, true, null);
}
@Test
void whenAllStepsSucceed_thenSagaCompleted() {
SagaState s = new SagaState("BK-1", new BigDecimal("15000000"));
assertThat(saga.start(s)).get().extracting(SagaCommand::step).isEqualTo(Step.FLIGHT);
assertThat(saga.onReply(s, ok(Step.FLIGHT, Action.RESERVE)))
.get().extracting(SagaCommand::step).isEqualTo(Step.HOTEL);
assertThat(saga.onReply(s, ok(Step.HOTEL, Action.RESERVE)))
.get().extracting(SagaCommand::step).isEqualTo(Step.PAYMENT);
assertThat(saga.onReply(s, ok(Step.PAYMENT, Action.RESERVE))).isEmpty();
assertThat(s.status()).isEqualTo(SagaStatus.COMPLETED);
}
@Test
void whenPaymentFails_thenCompensateHotelThenFlight() {
SagaState s = new SagaState("BK-1", new BigDecimal("15000000"));
saga.start(s);
saga.onReply(s, ok(Step.FLIGHT, Action.RESERVE));
saga.onReply(s, ok(Step.HOTEL, Action.RESERVE));
SagaCommand first = saga.onReply(s,
new SagaReply("BK-1", Step.PAYMENT, Action.RESERVE, false, "Card declined")).orElseThrow();
assertThat(first).extracting(SagaCommand::step, SagaCommand::action)
.containsExactly(Step.HOTEL, Action.CANCEL);
assertThat(s.status()).isEqualTo(SagaStatus.COMPENSATING);
SagaCommand second = saga.onReply(s, ok(Step.HOTEL, Action.CANCEL)).orElseThrow();
assertThat(second).extracting(SagaCommand::step, SagaCommand::action)
.containsExactly(Step.FLIGHT, Action.CANCEL);
assertThat(saga.onReply(s, ok(Step.FLIGHT, Action.CANCEL))).isEmpty();
assertThat(s.status()).isEqualTo(SagaStatus.CANCELLED);
}
}
The second test checks the most important behavior of a saga. Payment failure must trigger hotel cancellation followed by flight cancellation, and the saga must end in CANCELLED. For end-to-end testing, you can use @EmbeddedKafka from spring-kafka-test or Testcontainers. Logic should be tested at the unit level as shown above.
10. Common pitfalls
Dual write. The participant writes to database then sends a reply. If the service crashes between the two operations, the saga gets stuck. The standard solution is the Transactional Outbox: write the reply to an outbox table in the same transaction as the business data. Then a separate process (or Debezium CDC) pushes these records to Kafka.
Missing idempotency. Consumer rebalance or retries will resend messages. Every handler, including compensation handlers, must safely handle duplicates. In the orchestrator, you should also track which step is awaiting a reply and ignore mismatched replies.
No key. If you send a message without a key, messages for the same booking may fall into different partitions. Then a CANCEL command might be processed before a RESERVE command.
No isolation. Sagas don’t have Isolation like ACID. While a saga is running, another service may see a room as “reserved” even though it will be cancelled later. Use an intermediate state like PENDING (semantic lock) and only move to CONFIRMED when the saga completes.
No timeout. If a service never replies, the saga waits forever. You need a periodic job to scan overdue sagas for compensation. With CANCEL commands failing repeatedly, limit retry attempts and move the message to a dead-letter topic for manual handling.
Note: Don’t use Saga when all data lives in a single database. A plain
@Transactionalis simpler and safer. Saga is also unsuitable for operations that cannot tolerate intermediate states, or steps that cannot be compensated. For example, a sent confirmation email cannot be “recalled”. You should place such steps at the end of the saga.
11. Conclusion
In this article, you’ve implemented an orchestration-style Saga pattern with Kafka for a tour booking business process:
- Saga logic is isolated into a pure state machine, testable without a broker.
- The orchestrator sends commands and receives replies via Kafka, using
bookingIdas the key to maintain order. - Compensation steps are executed in reverse order thanks to a stack.
- Participants handle commands idempotently.
When deploying sagas to production, you need to add three things: persist saga state to a database, use the Transactional Outbox to avoid dual writes, and implement timeout mechanisms with dead-letter topics. These are what make your saga truly reliable.