Patrón Saga con Kafka para transacciones distribuidas: ejemplo de reserva de tours
1. Descripción general
Cuando una operación de negocio atraviesa múltiples microservicios, donde cada servicio tiene su propia base de datos, no puedes envolver todo en un simple @Transactional. El patrón Saga resuelve este problema. En lugar de una única transacción grande, ejecutas una serie de transacciones locales. Cada paso tiene una acción de compensación para revertirse si el paso siguiente falla.
En este artículo, implementarás una saga de tipo orquestación usando Spring Boot 3 y Kafka para la operación de reserva de tours. Cuando termines, tendrás un orquestador que coordina los pasos, servicios participantes que responden a través de Kafka, y un conjunto de pruebas para el flujo de compensación.
2. El problema de la reserva de tours
Una reserva de tour consta de tres pasos, cada uno perteneciente a un servicio diferente:
- FlightService: reservar asiento en el vuelo.
- HotelService: reservar habitación de hotel.
- PaymentService: cobrar al cliente.
Supongamos que se reservaron exitosamente el vuelo y la habitación, pero el pago falla porque la tarjeta ha alcanzado su límite de crédito. En este caso, el sistema debe cancelar la habitación y luego cancelar el vuelo. Si no lo hace, el cliente ocuparía un lugar sin pagar nada.
Two-phase commit (2PC) podría resolver esto, pero bloquea recursos durante toda la transacción. También requiere que todos los servicios soporten XA, y Kafka no puede participar en XA. Saga acepta eventual consistency: los datos pueden estar temporalmente inconsistentes, pero al final serán consistentes.
3. Orquestación vs. Coreografía
Saga tiene dos formas de organizarse:
| Criterio | Coreografía | Orquestación |
|---|---|---|
| Coordinación | Cada servicio escucha eventos de otros servicios | Un orquestador envía comandos, recibe respuestas |
| Flujo de negocio | Disperso entre múltiples servicios | Centralizado en un lugar |
| Mejor para | 2–3 pasos, cambios infrecuentes | Muchos pasos, lógica de compensación compleja |
| Riesgos | Dependencias circulares, difícil de depurar | El orquestador se convierte en un punto único de lógica |
Con tres pasos y un flujo de compensación invertido, la orquestación es más fácil de leer y probar. Este artículo usa ese enfoque. Los tópicos de Kafka se organizan de la siguiente manera:
tour.commands.flight ← orquestador envía comandos a FlightService
tour.commands.hotel ← orquestador envía comandos a HotelService
tour.commands.payment ← orquestador envía comandos a PaymentService
tour.replies ← todos los servicios envían resultados al orquestador
4. Configuración del proyecto
4.1. Dependencias
Necesitas agregar Spring for Apache Kafka. Spring Boot gestiona la versión automáticamente:
<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. Configuración
Los mensajes se envían en formato JSON. Configura el serializador y deserializador así:
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 escribe el nombre de la clase en el encabezado __TypeId__. En el lado del consumidor, JsonDeserializer lee ese encabezado para saber qué objeto crear. trusted.packages limita qué paquetes están permitidos para deserializar, evitando vulnerabilidades de seguridad.
A continuación, declara los tópicos:
@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. Modelo de mensajes
Usa record de Java 21 para representar comandos y respuestas:
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) {}
Cada mensaje lleva un bookingId. Este valor es tanto el identificador de la saga como la clave del mensaje cuando se envía a Kafka. Gracias a esto, todos los mensajes de la misma reserva caen en la misma partición y se procesan en el orden correcto.
6. Define saga como una máquina de estados
La parte más importante es la lógica que decide el siguiente paso. Deberías separar esta lógica de Kafka para poder hacer pruebas unitarias puras.
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 es una pila que almacena los pasos completados exitosamente. Cuando necesitas compensar, haces pop desde la cima de la pila, por lo que los pasos se revierten en orden inverso.
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 ya terminó, ignorar respuesta duplicada
}
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)); // reintentar cancelación
}
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());
}
}
La función onReply maneja cuatro casos:
- RESERVE exitoso: registra ese paso en la pila. Si es el último paso, la saga pasa a
COMPLETED. Si no, la saga envía un comando RESERVE para el siguiente paso. - RESERVE falla: la saga cambia a
COMPENSATINGy comienza a cancelar el paso más reciente completado. - CANCEL exitoso: la saga continúa cancelando el paso anterior. Cuando la pila está vacía, la saga pasa a
CANCELLED. - CANCEL falla: la saga reenvía el comando de cancelación. La compensación no puede ser incompleta.
Nota que el paso que falla no está en la pila. Si el pago falla, no hay nada que reembolsar, así que la saga solo necesita cancelar la habitación y el vuelo.
7. Orquestador
El orquestador hace dos cosas: envía comandos a Kafka y escucha respuestas de 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() usa bookingId como clave. El nombre del tema se deduce de Step, por ejemplo HOTEL se convierte en tour.commands.hotel.
Nota:
ConcurrentHashMapsolo se usa para demostración. En producción, el estado de la saga debe guardarse en una base de datos. Si el orquestador se reinicia a mitad del proceso, necesita releer el estado para continuar o compensar.
8. Servicio participante
Cada participante escucha el tema de comandos, ejecuta una transacción local y envía una respuesta. Por ejemplo, con 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 : "Sin habitaciones disponibles"));
}
private boolean reserve(String bookingId) {
if (reservedRooms.contains(bookingId)) {
return true; // ya reservado, el mensaje fue reenviado
}
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; // cancelar algo que nunca se reservó sigue siendo exitoso
}
}
Dos detalles importantes en el código anterior:
reserve()es idempotente. Kafka garantiza entrega at-least-once, así que el mismo comando puede llegar dos veces. Si ya has reservado una habitación para esebookingId, el manejador devuelve éxito sin reducir las habitaciones de nuevo.cancel()también es idempotente y siempre tiene éxito. El comando de cancelación puede llegar antes del comando de reserva, u ocurrir para una reserva que nunca existió. Ambos casos no deben dañar los datos.
FlightCommandHandler y PaymentCommandHandler tienen una estructura similar. Solo para PaymentService, CANCEL significa reembolso.
Cuando PaymentService rechaza el pago, el registro del orquestador será el siguiente:
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
La habitación se cancela primero, el vuelo se cancela después, exactamente en el orden inverso de cuando se reservaban.
9. Pruebas del flujo saga
Como TourBookingSaga no depende de Kafka, puedes probarlo con JUnit 5 y AssertJ sin necesidad de 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, "Tarjeta rechazada")).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);
}
}
La segunda prueba verifica el comportamiento más importante de la saga. Si el pago falla, debe cancelar la habitación y luego el vuelo, y la saga debe terminar en CANCELLED. Para pruebas end-to-end, puedes usar @EmbeddedKafka de spring-kafka-test o Testcontainers. La lógica debe ser probada a nivel unitario como arriba.
10. Errores comunes
Escritura dual. El participante escribe en la base de datos y luego envía una respuesta. Si el servicio muere entre las dos operaciones, la saga se cuelga. La solución estándar es Transactional Outbox: escribe la respuesta en la tabla outbox en la misma transacción que los datos de negocio. Luego, un proceso separado (o Debezium CDC) envía esos registros a Kafka.
Falta de idempotencia. El reequilibrio del consumidor o los reintentos reenviarán el mensaje. Cada manejador, incluyendo el manejador de compensación, debe manejar duplicados de forma segura. En el orquestador, deberías guardar el paso esperando respuesta y descartar respuestas que no coincidan.
No usar clave. Si envías un mensaje sin clave, los mensajes de la misma reserva pueden caer en muchas particiones diferentes. Entonces el comando CANCEL puede ser procesado antes del comando RESERVE.
Falta de aislamiento. La saga no tiene la propiedad de Aislamiento como ACID. Mientras la saga se está ejecutando, otro servicio puede ver la habitación “reservada” incluso si se cancela más tarde. Deberías usar un estado intermedio como PENDING (bloqueo semántico) y solo cambiar a CONFIRMED cuando la saga se complete.
Falta de tiempo de espera. Si un servicio nunca responde, la saga esperará para siempre. Necesitas un trabajo periódico que escanee las sagas vencidas para compensar. Con comandos CANCEL que fallan continuamente, limita el número de reintentos y transfiere el mensaje a un tema de carta muerta para que los humanos lo manejen.
Nota: No uses Saga cuando todos los datos están en la misma base de datos. Un
@Transactionalnormal es mucho más simple y seguro. Saga tampoco es adecuada para operaciones que no aceptan estados intermedios, o que tienen pasos que no pueden compensarse. Por ejemplo, un correo electrónico de confirmación enviado no puede ser “recuperado”. Deberías colocar estos pasos al final de la saga.
11. Conclusión
En este artículo, has implementado el patrón Saga de orquestación con Kafka para la operación de reserva de tours:
- La lógica de saga se separa en una máquina de estados pura, que se puede probar sin necesidad de broker.
- El orquestador envía comandos y recibe respuestas a través de Kafka, usando
bookingIdcomo clave para mantener el orden. - Los pasos de compensación se ejecutan en orden inverso gracias a una pila.
- El participante procesa comandos de manera idempotente.
Cuando implementas saga en producción, necesitas agregar tres cosas: guardar el estado de la saga en una base de datos, usar Transactional Outbox para evitar escritura dual, y tener un mecanismo de tiempo de espera con un tema de carta muerta. Estas son las partes que determinan si tu saga es realmente confiable o no.