Una transacción entre servicios (Saga)
En resumen: Una Saga es una transacción entre varios servicios en forma de lista de pasos, cada uno con un paso de compensación; si algo falla, las compensaciones corren hacia atrás, empezando por el último paso hecho.
Problema
El dinero está cobrado, los audífonos ya no están
Un cliente pide los últimos audífonos por 59 € (pedido 1042). El pago se realiza. Un segundo después el almacén responde: «no hay en stock». Otro cliente se llevó la última unidad justo antes.
var payments = new Payments();var warehouse = new Warehouse(Map.of("audífonos", 0));var delivery = new Delivery();var order = new Order(1042, "audífonos", 59, "Varna");try { payments.charge(order); // el pago se realiza warehouse.reserve(order); // el almacén: sin stock delivery.book(order);} catch (StepFailed e) { System.out.println(" ✗ " + e.getMessage());}System.out.println("cobrado de la tarjeta: " + payments.charged + " €"); ✓ cobrado 59 € ✗ no hay audífonos en stockcobrado de la tarjeta: 59 €El programa captura el error, pero el dinero queda cobrado. Mañana el cliente escribe a soporte, y alguien devuelve 59 € a mano.
En un monolito con una sola base de datos la respuesta es una transacción: todo o nada (la viste en la lección sobre bases de datos y en «ACID: todo o nada»). Aquí, sin embargo, el pago está en un proveedor externo, y el almacén es un servicio aparte con su propia base de datos. No hay una transacción que cubra los tres.
La idea
Una Saga: pasos con pasos inversos
Si no puedes volver atrás, haces acciones nuevas que arreglan las consecuencias. Cada paso del pedido viene con un paso inverso (una compensación):
| Paso | Servicio | Paso inverso |
|---|---|---|
| cobra 59 € | pagos | devuelve 59 € |
| reserva los audífonos | almacén | los libera |
| contrata un mensajero | entrega | cancela al mensajero |
Cada paso es una pequeña transacción local en su propio servicio. Si un paso rechaza, los pasos inversos de los pasos ya hechos corren hacia atrás: lo último que se hizo es lo primero que se deshace. Esto se llama Saga.
Un paso inverso no es un rollback. Un rollback borra, como si nada hubiera pasado. La devolución de 59 € es un segundo pago en sentido contrario, que el cliente ve en su banco junto al primero. Por eso, entre los pasos el sistema está en un estado intermedio («el pedido está en proceso»), y todos tienen que saber convivir con él.
Aquí la Saga la dirige un objeto, el orquestador: conoce los pasos, los llama en orden y, cuando algo falla, llama a las compensaciones. Los pasos están en una lista y las compensaciones en una pila: la última que entra es la primera que sale.
Míralo
Una Saga en código
El rechazo de un servicio es una excepción. En este sistema pequeño los servicios son objetos en un solo proceso, y los rechazos siguen un guion: hay un solo par de audífonos, y a Vidin no hay mensajero.
record Order(int id, String item, int price, String city) {}
class StepFailed extends RuntimeException { StepFailed(String reason) { super(reason); }}
record Step(Consumer<Order> action, Consumer<Order> undo) {}class Warehouse { private final Map<String, Integer> stock;
Warehouse(Map<String, Integer> stock) { this.stock = new HashMap<>(stock); }
void reserve(Order o) { int left = stock.getOrDefault(o.item(), 0); if (left == 0) throw new StepFailed("no hay " + o.item() + " en stock"); stock.put(o.item(), left - 1); System.out.println(" ✓ reservado: " + o.item()); }
void release(Order o) { stock.merge(o.item(), 1, Integer::sum); System.out.println(" ↩ liberado: " + o.item()); }}Step es un record con dos funciones: un Consumer<Order> hace el paso, el otro lo deshace. Los pagos y la entrega son como el almacén: un método para el paso y otro para el inverso.
El orquestador:
class Saga { private final List<Step> steps;
Saga(List<Step> steps) { this.steps = steps; }
String run(Order order) { Deque<Consumer<Order>> undo = new ArrayDeque<>(); try { for (Step step : steps) { step.action().accept(order); undo.push(step.undo()); } return "confirmado"; } catch (StepFailed e) { System.out.println(" ✗ " + e.getMessage()); while (!undo.isEmpty()) undo.pop().accept(order); return "cancelado"; } }}Paso a paso, en palabras
- Pedido 1043: un cargador por 25 € a Vidin. Cada paso vive en otro servicio, y cada uno tiene un inverso: cobrar ↔ devolver, reservar ↔ liberar, contratar ↔ cancelar.
- Paso 1: Payments cobra 25 €. Su paso inverso va a la pila undo (push).
- Paso 2: Warehouse reserva el cargador. «Liberar» se coloca sobre «devolver».
- Paso 3: no hay mensajero a Vidin, y Delivery rechaza. Los dos primeros pasos ya ocurrieron en otros sistemas, y no existe un rollback que los cubra.
- La Saga captura el rechazo y da marcha atrás: saca las compensaciones de la pila, la de arriba primero.
- pop entrega la última que entró: primero se libera el cargador. Lo último que se hizo es lo primero que se deshace.
- Luego el pago: 25 € vuelven a la tarjeta. Una devolución es una acción nueva, no un borrado: el cliente ve las dos en su banco.
- Cancelado: el cargador vuelve a estar en stock y quedan 0 € cobrados. Todos los sistemas vuelven a coincidir, con acciones posteriores en lugar de una sola transacción.
Tres pedidos: el 1041 pasa, el 1042 se detiene en el almacén, el 1043 se detiene en la entrega.
var payments = new Payments();var warehouse = new Warehouse(Map.of("audífonos", 1, "cargador", 5));var delivery = new Delivery();var saga = new Saga(List.of( new Step(payments::charge, payments::refund), new Step(warehouse::reserve, warehouse::release), new Step(delivery::book, delivery::cancel)));var orders = List.of( new Order(1041, "audífonos", 59, "Sofía"), new Order(1042, "audífonos", 59, "Varna"), new Order(1043, "cargador", 25, "Vidin"));for (Order order : orders) { System.out.println("pedido " + order.id()); System.out.println(" = " + saga.run(order));}System.out.println("cobrado de las tarjetas: " + payments.charged + " €");pedido 1041 ✓ cobrado 59 € ✓ reservado: audífonos ✓ mensajero a Sofía = confirmadopedido 1042 ✓ cobrado 59 € ✗ no hay audífonos en stock ↩ devuelto 59 € = canceladopedido 1043 ✓ cobrado 25 € ✓ reservado: cargador ✗ no hay mensajero a Vidin ↩ liberado: cargador ↩ devuelto 25 € = canceladocobrado de las tarjetas: 59 €Al final hay 59 € cobrados de las tarjetas: solo por el pedido que pasó.
Pruébalo
Predice la salida
La misma Saga, pero el almacén es el primer paso y el pago el segundo. Los audífonos se agotaron. ¿Qué imprime?
var payments = new Payments();var warehouse = new Warehouse(Map.of("audífonos", 0));var delivery = new Delivery();var saga = new Saga(List.of( new Step(warehouse::reserve, warehouse::release), new Step(payments::charge, payments::refund), new Step(delivery::book, delivery::cancel)));System.out.println(" = " + saga.run(new Order(1042, "audífonos", 59, "Varna")));El almacén rechaza ya en el primer paso, así que la pila está vacía: no hay dinero que devolver. El orden de los pasos es una decisión de arquitectura. Primero van los pasos que fallan con más frecuencia y que son más baratos de deshacer. El pago, que cuesta una comisión y molesta al cliente, va después.
La idea
Lo que no se puede deshacer
No todo paso tiene un inverso. El correo «tu pedido está confirmado» no se puede mandar de vuelta. Un paquete que el mensajero ya recogió no regresa con una sola llamada.
Por eso esos pasos van al final, después de todo lo que puede rechazar. El paso a partir del cual la Saga solo avanza se llama pivot (punto de giro). Después de él, los fallos no se compensan, sino que se reintentan hasta que pasen: con timeout y retry.
Pruébalo
Encuentra el error
Un colega reordena el bucle y las pruebas pasan: cuando algo falla, el dinero se devuelve. Una semana después, contabilidad encuentra devoluciones de pagos que nunca ocurrieron: la tarjeta fue rechazada y el cliente recibió 59 €. ¿En qué línea está el error?
Elige la línea con el error.
El paso inverso entra en la pila antes que el paso mismo. Si el pago rechaza (la tarjeta es rechazada), su devolución ya está en la pila y se llama: el cliente recibe dinero que nunca pagó. Una compensación se anota solo después de que el paso haya tenido éxito.
La idea
Orquestación o coreografía
El orquestador es un lugar que conoce todo el proceso. La otra forma no tiene ese lugar: cada servicio escucha eventos y responde con uno propio. Los pagos oyen «pedido» y dicen «cobrado». El almacén oye «cobrado» y dice «reservado» o «sin stock». Los pagos oyen «sin stock» y devuelven el dinero. Esto se llama coreografía.
El bus es una cola en memoria con suscripciones, como en la lección sobre eventos:
record Event(String type, int order) {}
class Bus { private final Map<String, List<Consumer<Event>>> handlers = new HashMap<>(); private final Deque<Event> queue = new ArrayDeque<>();
void on(String type, Consumer<Event> handler) { handlers.computeIfAbsent(type, t -> new ArrayList<>()).add(handler); }
void publish(Event event) { queue.add(event); }
void run() { while (!queue.isEmpty()) { Event e = queue.poll(); System.out.println(e.type() + " " + e.order()); for (var handler : handlers.getOrDefault(e.type(), List.of())) handler.accept(e); } }}El mismo rechazo del 1042, como cadena de eventos:
var bus = new Bus();// los pagosbus.on("OrderPlaced", e -> bus.publish(new Event("PaymentCharged", e.order())));bus.on("StockMissing", e -> bus.publish(new Event("PaymentRefunded", e.order())));// el almacén (en este ejemplo la mercancía del 1042 se agotó)bus.on("PaymentCharged", e -> bus.publish(new Event("StockMissing", e.order())));// los pedidosbus.on("PaymentRefunded", e -> bus.publish(new Event("OrderCancelled", e.order())));bus.publish(new Event("OrderPlaced", 1042));bus.run();OrderPlaced 1042PaymentCharged 1042StockMissing 1042PaymentRefunded 1042OrderCancelled 1042| Orquestación | Coreografía | |
|---|---|---|
| Dónde está el proceso | en un objeto | repartido entre las suscripciones |
| Un paso nuevo | una línea en la lista | una suscripción nueva y un evento nuevo |
| «¿Por qué se detuvo el 1042?» | el estado del orquestador | reúnes los eventos de cada servicio |
| Acoplamiento | el orquestador conoce a todos | cada servicio conoce solo los eventos |
Con dos o tres pasos, la coreografía es más ligera. Cuando hay cinco pasos con condiciones, el proceso se pierde y el orquestador es la opción más clara.
La idea
La compensación llega dos veces
Entre servicios, los comandos y los eventos pasan por una cola, y la cola entrega al menos una vez (lo viste en la lección sobre eventos). Si «devolver 59 € por el 1042» llega dos veces, el cliente recibe 118 €.
Por eso cada paso y cada compensación es idempotente: la segunda vez, con la misma entrada, no hace nada. La clave es la acción y el pedido. Hacia el proveedor real se envía como clave de idempotencia, para que también él rechace la devolución repetida.
class Refunds { private final Set<String> done = new HashSet<>(); int total = 0;
void refund(int order, int euros) { // la clave del paso: acción + pedido if (!done.add("refund:" + order)) { System.out.println(" · " + order + ": ya devuelto"); return; } total += euros; System.out.println(" ↩ devuelto " + euros + " € por " + order); }}var refunds = new Refunds();// la cola entrega «al menos una vez»: el 1042 llega dos vecesrefunds.refund(1042, 59);refunds.refund(1042, 59);refunds.refund(1043, 25);System.out.println("devuelto en total: " + refunds.total + " €"); ↩ devuelto 59 € por 1042 · 1042: ya devuelto ↩ devuelto 25 € por 1043devuelto en total: 84 €Pruébalo
Completa
¿Qué comprueba si la devolución ya se hizo?
if (!done.("refund:" + order)) {
System.out.println(" · " + order + ": ya devuelto");
return;
}Set.add devuelve false si la clave ya está en el conjunto. La comprobación y el registro son una sola llamada. En un servicio real las claves están en la base de datos, en la misma transacción que la devolución; si no, una caída entre las dos vuelve a producir un duplicado.
La idea
Cuándo una Saga perjudica
Una Saga no es gratis:
- El código se duplica. Cada paso necesita un inverso, pruebas para los dos y pruebas de fallo en cada punto.
- Los estados intermedios se ven. El cliente ve «en proceso», y el dinero está cobrado un momento antes de volver. Otra persona puede leer un pedido que luego se cancela: una Saga no tiene aislamiento como una transacción.
- Un fallo es más difícil de investigar. Los pasos están en servicios distintos, con registros distintos.
Por eso no la uses donde basta una transacción. Si el stock y los pedidos son módulos de un solo monolito con una base de datos, una transacción los protege de forma más simple y más segura. La Saga es para los pasos que salen del sistema: un proveedor externo, otro servicio, otra empresa.
Más precisamente, para curiosos
¿Por qué no dos fases (2PC)? Existe un protocolo para una transacción común entre varias bases de datos: el coordinador pregunta a todos «¿están listos?» y después les dice a todos «confirmen». El proveedor externo de pagos no participa en un protocolo así. Y mientras el coordinador espera, todos mantienen filas bloqueadas; si se cae a la mitad, se quedan bloqueadas. Por eso entre servicios casi siempre se usa una Saga.
El orquestador también se cae. Si el proceso se detiene después del pago, ¿cómo sabe hasta dónde llegó? Un orquestador real guarda el estado de cada Saga en una base de datos (paso 2 de 3, pedido 1042) y sigue tras un reinicio. Los comandos a los servicios salen por una outbox, para que no se pierdan entre la escritura y el envío.
Un bloqueo semántico. Un pedido «en proceso» es una señal para los demás: no lo mandes a facturar, no permitas cambiar la dirección. Así se compensa la falta de aislamiento.
Herramientas ya hechas: Temporal, Camunda y AWS Step Functions guardan el estado de procesos largos y reintentan los pasos. También hay bibliotecas de Saga en los frameworks: Axon (Java), MassTransit y NServiceBus (.NET). La cola entre servicios en un proyecto real es Kafka o RabbitMQ. Cómo se ponen en marcha y se vigilan está en el curso de DevOps ZeroToOps.
El nombre viene de un artículo de Hector Garcia-Molina y Kenneth Salem de 1987 sobre transacciones largas en bases de datos.
Ejercicio
Puntos de lealtad
Resuélvelo en tu editor.
La tienda da 1 punto por cada 10 € completos. Añade un servicio Loyalty con un método para el paso (da los puntos) y otro para el inverso (los quita). Ponlo en la Saga como tercer paso, entre el almacén y la entrega. Ejecuta el pedido 1044: un parlante por 120 € a Vidin, con 2 parlantes en el almacén. Al final imprime los puntos y el dinero cobrado.
pedido 1044 ✓ cobrado 120 € ✓ reservado: parlante ✓ +12 puntos ✗ no hay mensajero a Vidin ↩ -12 puntos ↩ liberado: parlante ↩ devuelto 120 € = canceladopuntos: 0, cobrado: 0 €Más tareas
- Una tarjeta rechazada. Haz que el pago rechace por encima de 100 € («tarjeta rechazada») y ejecuta el 1044 a Sofía. ¿Qué hay en la pila cuando el pago rechaza?
- Coreografía. Añade al bus los eventos
StockReservedyOrderConfirmedy ejecuta un pedido para el que sí hay mercancía.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Transactional Outbox · Diseño de API: contrato, versiones, solicitudes repetidas · Trade-offs · Cuando el vecino es lento: timeout, retry, circuit breaker
- Continúa en: Integrar sistemas antiguos y ajenos · Cambiar un sistema en marcha (Strangler Fig, Expand–Contract) · Tu primera tarea de arquitectura
- Ejercicios sobre el tema: El arquitecto en acción