StackPractices
advanced Por Mathias Paulenko

Patrón Saga: orquestación vs coreografía

Gestiona transacciones distribuidas entre varios servicios encadenando transacciones locales con acciones compensatorias para el rollback.

Temas: design

Resumen

El Patrón Saga gestiona transacciones distribuidas entre varios servicios dividiendo una operación de negocio de larga duración en una secuencia de transacciones locales. Cada transacción local actualiza un único servicio y publica un evento o comando que dispara el siguiente paso. Si un paso falla, la saga ejecuta transacciones compensatorias — operaciones de negocio que deshacen el trabajo — sobre los pasos que ya hicieron commit, en orden inverso.

Una saga no es un rollback de base de datos. Cada transacción local hace commit por su cuenta, así que sus efectos son visibles para otras operaciones mientras la saga sigue corriendo. La compensación es un deshacer semántico — reembolsar el cargo, cancelar la reserva — no uno físico: el commit original queda en el historial.

Para una implementación con forma de producción conectada a un bus de eventos, consulta la receta de saga.

Cuándo usarlo

Usa el Patrón Saga cuando:

  • Una operación de negocio abarca varios servicios que poseen su propia base de datos — checkout de e-commerce (pedido → pago → inventario → envío), reservas de viaje, transferencias de dinero
  • El commit de dos fases (2PC) no está disponible o no es aceptable: necesita almacenes compatibles con XA, mantiene bloqueos entre servicios, y la mayoría de stacks NoSQL y REST no lo soportan
  • Los pasos duran de segundos a días — pagos, aprobaciones manuales, envíos — y deben sobrevivir a reinicios del servicio
  • Cada paso anterior al punto de no retorno tiene una acción compensatoria factible: reembolsar el pago, liberar el stock, cancelar el envío

Evítalo cuando toda la operación cabe en una sola base de datos — una transacción ACID normal es más simple — cuando la consistencia estricta entre todos los pasos es un requisito legal, o cuando un paso clave no puede compensarse. Una saga que no puedes deshacer es un problema, no un patrón.

Solución

Una saga orquestada en tres lenguajes. El orquestador mantiene una lista ordenada de pasos, ejecuta cada acción, registra lo completado y, ante un fallo, recorre los pasos confirmados hacia atrás ejecutando sus compensaciones. El paso cobrar_pago siempre falla en la demo para que puedas ver el camino de reversa.

Python

from typing import Callable, List, Dict, Any
from dataclasses import dataclass

@dataclass
class SagaResult:
    success: bool
    data: Any = None
    error: str = None
    step_index: int = 0

class SagaStep:
    def __init__(self, name: str, action: Callable, compensation: Callable = None):
        self.name = name
        self.action = action
        self.compensation = compensation

class SagaOrchestrator:
    def __init__(self):
        self.steps: List[SagaStep] = []
        self.completed: List[Dict] = []

    def add_step(self, name: str, action: Callable, compensation: Callable = None):
        self.steps.append(SagaStep(name, action, compensation))

    def execute(self, context: Dict) -> SagaResult:
        self.completed = []
        for i, step in enumerate(self.steps):
            try:
                result = step.action(context)
                self.completed.append({"step": step.name, "context": dict(context)})
                print(f"Paso '{step.name}' completado")
            except Exception as e:
                print(f"Paso '{step.name}' falló: {e}")
                self.rollback(i)
                return SagaResult(success=False, error=str(e), step_index=i)
        return SagaResult(success=True, data=context)

    def rollback(self, failed_index: int):
        print(f"Revirtiendo {failed_index} pasos completados...")
        for j in range(failed_index - 1, -1, -1):
            step = self.steps[j]
            if step.compensation:
                try:
                    state = self.completed[j]
                    step.compensation(state["context"])
                    print(f"Compensado '{step.name}'")
                except Exception as e:
                    print(f"Compensación falló para '{step.name}': {e}")

# Uso: saga de reserva de viaje
saga = SagaOrchestrator()

saga.add_step(
    "reservar_vuelo",
    action=lambda ctx: ctx.update({"flight": "FL123"}) or True,
    compensation=lambda ctx: print("Cancelando reserva de vuelo")
)

saga.add_step(
    "reservar_hotel",
    action=lambda ctx: ctx.update({"hotel": "HT456"}) or True,
    compensation=lambda ctx: print("Cancelando reserva de hotel")
)

saga.add_step(
    "cobrar_pago",
    action=lambda ctx: (_ for _ in ()).throw(Exception("Pago declinado")),
    compensation=lambda ctx: print("Reembolsando pago")
)

result = saga.execute({"user": "alice"})
print(f"Éxito saga: {result.success}")

JavaScript

class SagaStep {
  constructor(name, action, compensation) {
    this.name = name;
    this.action = action;
    this.compensation = compensation;
  }
}

class SagaOrchestrator {
  constructor() {
    this.steps = [];
    this.completed = [];
  }

  addStep(name, action, compensation) {
    this.steps.push(new SagaStep(name, action, compensation));
  }

  async execute(context) {
    this.completed = [];
    for (let i = 0; i < this.steps.length; i++) {
      try {
        await this.steps[i].action(context);
        this.completed.push({ step: this.steps[i].name, context: { ...context } });
        console.log(`Paso '${this.steps[i].name}' completado`);
      } catch (e) {
        console.log(`Paso '${this.steps[i].name}' falló: ${e.message}`);
        await this.rollback(i);
        return { success: false, error: e.message, stepIndex: i };
      }
    }
    return { success: true, data: context };
  }

  async rollback(failedIndex) {
    console.log(`Revirtiendo ${failedIndex} pasos completados...`);
    for (let j = failedIndex - 1; j >= 0; j--) {
      const step = this.steps[j];
      if (step.compensation) {
        try {
          await step.compensation(this.completed[j].context);
          console.log(`Compensado '${step.name}'`);
        } catch (e) {
          console.log(`Compensación falló para '${step.name}': ${e.message}`);
        }
      }
    }
  }
}

// Uso
const saga = new SagaOrchestrator();
saga.addStep("reservarVuelo",
  async (ctx) => { ctx.flight = "FL123"; },
  async () => console.log("Cancelando vuelo")
);
saga.addStep("reservarHotel",
  async (ctx) => { ctx.hotel = "HT456"; },
  async () => console.log("Cancelando hotel")
);
saga.addStep("cobrarPago",
  async () => { throw new Error("Pago declinado"); },
  async () => console.log("Reembolsando pago")
);

saga.execute({ user: "alice" }).then(r => console.log("Éxito:", r.success));

Java

import java.util.*;
import java.util.function.Consumer;

class SagaStep {
    String name;
    Consumer<Map<String, Object>> action;
    Consumer<Map<String, Object>> compensation;

    SagaStep(String name, Consumer<Map<String, Object>> action, Consumer<Map<String, Object>> compensation) {
        this.name = name;
        this.action = action;
        this.compensation = compensation;
    }
}

class SagaResult {
    boolean success;
    String error;
    int stepIndex;

    SagaResult(boolean success, String error, int stepIndex) {
        this.success = success;
        this.error = error;
        this.stepIndex = stepIndex;
    }
}

class SagaOrchestrator {
    private final List<SagaStep> steps = new ArrayList<>();
    private final List<Map<String, Object>> completed = new ArrayList<>();

    void addStep(String name, Consumer<Map<String, Object>> action, Consumer<Map<String, Object>> compensation) {
        steps.add(new SagaStep(name, action, compensation));
    }

    SagaResult execute(Map<String, Object> context) {
        completed.clear();
        for (int i = 0; i < steps.size(); i++) {
            try {
                steps.get(i).action.accept(context);
                completed.add(new HashMap<>(context));
                System.out.println("Paso '" + steps.get(i).name + "' completado");
            } catch (Exception e) {
                System.out.println("Paso '" + steps.get(i).name + "' falló: " + e.getMessage());
                rollback(i);
                return new SagaResult(false, e.getMessage(), i);
            }
        }
        return new SagaResult(true, null, steps.size());
    }

    void rollback(int failedIndex) {
        System.out.println("Revirtiendo " + failedIndex + " pasos completados...");
        for (int j = failedIndex - 1; j >= 0; j--) {
            SagaStep step = steps.get(j);
            if (step.compensation != null) {
                try {
                    step.compensation.accept(completed.get(j));
                    System.out.println("Compensado '" + step.name + "'");
                } catch (Exception e) {
                    System.out.println("Compensación falló: " + e.getMessage());
                }
            }
        }
    }
}

// Uso
SagaOrchestrator saga = new SagaOrchestrator();
saga.addStep("reservarVuelo",
    ctx -> ctx.put("flight", "FL123"),
    ctx -> System.out.println("Cancelando vuelo")
);
saga.addStep("reservarHotel",
    ctx -> ctx.put("hotel", "HT456"),
    ctx -> System.out.println("Cancelando hotel")
);
saga.addStep("cobrarPago",
    ctx -> { throw new RuntimeException("Pago declinado"); },
    ctx -> System.out.println("Reembolsando pago")
);

SagaResult result = saga.execute(new HashMap<>(Map.of("user", "alice")));
System.out.println("Éxito: " + result.success);

Explicación

Orquestación vs coreografía

El código de arriba es orquestado: un coordinador decide qué paso sigue y cuándo compensar. En una saga coreografiada no hay coordinador — cada servicio confirma localmente, emite un evento, y el siguiente servicio reacciona:

PasoServicioPublica si hay éxitoCompensación si falla
1VuelosFlightReserved → escucha hotelesCancelFlight ante BookingFailed
2HotelesHotelReserved → escucha pagosCancelHotel ante BookingFailed
3PagosPaymentCharged → pedido confirmadoRefundPayment ante BookingFailed

La orquestación te da un solo lugar para leer, depurar y reejecutar el flujo, a costa de un coordinador con estado que tienes que operar y escalar. La coreografía elimina el coordinador pero dispersa el flujo entre los codebases — bien para dos o tres pasos, doloroso pasado ese punto. Cuando los servicios emiten eventos, entrégalos con el patrón outbox para que el commit local y el evento no puedan desincronizarse; si los eventos son tu fuente de verdad, eso combina con event sourcing y CQRS en el lado de lectura.

Una versión coreografiada mínima del mismo flujo de reserva. Cada servicio posee su paso y su compensación; la saga emerge de las suscripciones a eventos:

// Bus de eventos mínimo — en producción esto es Kafka, RabbitMQ o SNS
const bus = {
  subs: {},
  on(event, fn) { (this.subs[event] ??= []).push(fn); },
  async emit(event, data) { for (const fn of this.subs[event] ?? []) await fn(data); },
};

// Hoteles reacciona a la reserva del vuelo
bus.on("FlightReserved", async (e) => {
  try {
    await hotelService.reserve(e.bookingId);
    await bus.emit("HotelReserved", e);
  } catch {
    await bus.emit("BookingFailed", e);
  }
});

// Pagos reacciona a la reserva del hotel
bus.on("HotelReserved", async (e) => {
  try {
    await paymentService.charge(e.bookingId);
    await bus.emit("PaymentCharged", e);
  } catch {
    await bus.emit("BookingFailed", e);
  }
});

// Las compensaciones se suscriben al evento de fallo
bus.on("BookingFailed", async (e) => {
  await hotelService.cancel(e.bookingId);
  await flightService.cancel(e.bookingId);
});

La receta de saga conecta una versión completa de esto a servicios reales.

sequenceDiagram diagram: participant O as Orquestador

Las sagas son ACD, no ACID

Cada transacción local confirma de inmediato, así que el estado intermedio es visible. Otra operación puede observar un flujo a medio hacer — vuelo reservado, pago aún sin intentar — y dos sagas pueden pelearse por la misma fila. Esa pérdida de aislamiento es el costo real del patrón:

  • Lecturas sucias: una consulta ve vuelo_reservado antes de que la saga termine. Contramedida: un campo de estado que marca la intención (RESERVA_PENDIENTE) para que los lectores sepan que el estado es provisional — un bloqueo semántico.
  • Actualizaciones perdidas: dos sagas escriben el mismo registro y una gana en silencio. Contramedida: chequeos de versión, o actualizaciones conmutativas (sumar/quitar en vez de sobrescribir).
  • Lecturas difusas: un reporte lee estado a media saga y totaliza cifras que van a cambiar. Contramedida: marca el estado derivado como provisional hasta que la saga termine, o re-lee antes de actuar.

Diseña primero el camino de deshacer

Una compensación es una operación de negocio nueva, no un undo de base de datos: una vez que reservar_vuelo confirma, “des-reservar” significa llamar al endpoint de cancelación, y la reserva cancelada queda en el registro. Algunos pasos no pueden deshacerse — un email enviado, un paquete despachado — así que ordena la saga alrededor del paso pivote, la primera acción que no puede compensarse. Todo lo anterior al pivote debe ser compensable; todo lo posterior solo tiene que ser seguro de reintentar.

El orquestador tiene estado — persístelo

La lista completed en memoria de los ejemplos lo pierde todo si el proceso muere a media saga. Un orquestador de producción escribe cada transición en un log de saga — una tabla o un stream de eventos en el propio almacén del coordinador — y retoma desde el último paso confirmado al reiniciar. Persistir la propia saga es lo que permite que una reserva de tres días sobreviva a un deploy.

No tienes que construirlo desde cero

  • Temporal / Cadence: workflows durables con manejadores de compensación, timers y replay — lo más parecido a sagas con pilas incluidas.
  • Axon Framework, Eventuate Tram (Java): orquestación de sagas sobre mensajería o event sourcing.
  • MassTransit (.NET): máquinas de estado de saga con correlación de mensajes.
  • AWS Step Functions: orquestación serverless donde los bloques Catch actúan como compensaciones.
  • La receta de saga implementa ambos estilos; los workflow engines llevan la misma idea a la ejecución durable.

Variantes

VarianteDescripciónCaso de uso
Saga OrquestadaCoordinador central gestiona el flujoFlujos complejos o con ramificaciones que necesitan visibilidad
Saga CoreografiadaImpulsada por eventos, sin coordinadorFlujos lineales cortos; acoplamiento débil
Saga ParalelaPasos independientes corren en paraleloOperaciones sin dependencias entre sí
Saga AnidadaUn paso de la saga dispara otra sagaDescomposiciones complejas de dominio

Lo que funciona

  • Diseña las compensaciones primero — escribe el deshacer antes que la acción; si no puedes nombrar la compensación, ese paso no pertenece a una saga
  • Ordena alrededor del paso pivote: pasos compensables primero, el irreversible al final, los reintentables después
  • Claves de idempotencia en cada paso y compensación — reintentos, replays y consumidores idempotentes los re-ejecutarán
  • Persiste el estado de la saga para que un coordinador caído retome en vez de olvidar pasos confirmados
  • Timeout en cada paso: una respuesta que no llega debería disparar compensación, no esperar para siempre
  • Reintentos dentro del paso para fallos transitorios; compensa solo cuando los reintentos se agotan

Errores comunes

  • Tratar la compensación como un rollback real — el dato confirmado ya era visible para otras operaciones
  • Poner un paso irreversible (enviar el email, cobrar sin API de reembolso) al principio del flujo
  • Olvidar compensaciones para pasos con efectos secundarios — incluido el paso “inofensivo” que dispara una notificación
  • Ejecutar pasos de saga sin timeouts, dejando que un servicio muerto detenga el flujo indefinidamente
  • Asumir aislamiento: dos sagas sobre el mismo registro necesitan bloqueos semánticos o versionado
  • Guardar el progreso de la saga solo en memoria — la primera caída a media saga deja el flujo varado

Ver también

Preguntas frecuentes

¿Cuál es la diferencia entre Saga y 2PC?

2PC bloquea recursos en todos los participantes hasta el commit — consistencia fuerte, pero bloqueante y no disponible en la mayoría de stacks NoSQL y HTTP. Una saga confirma cada paso localmente y deshace por compensación, cambiando aislamiento por disponibilidad.

¿Cómo manejo una compensación que también falla?

Reinténtala — las compensaciones deberían ser idempotentes y seguras de re-ejecutar. Cuando los reintentos se agotan, envía la compensación fallida a una dead-letter queue y alerta a un operador: una compensación atascada es datos inconsistentes que necesitan reparación manual.

¿Orquestación o coreografía — cuál elegir?

Coreografía para flujos cortos y lineales de dos a cuatro pasos donde el acoplamiento débil es lo prioritario. Orquestación cuando el flujo se ramifica, cruza fronteras de equipo, o necesita un solo lugar para inspeccionar, reejecutar y depurar.

¿Pueden dos sagas tocar el mismo dato a la vez?

Sí — las sagas no dan aislamiento. Protege los registros compartidos con un bloqueo semántico (un campo de estado como RESERVA_PENDIENTE), chequeos de versión, o actualizaciones conmutativas, para que una segunda saga espere o falle limpio en vez de pisar a la primera.

¿Dónde vive el estado de la saga?

En el almacén del propio coordinador — una tabla de sagas o un log de eventos, no en memoria del proceso. Si el orquestador reinicia a media saga, recarga los pasos completados y retoma; los motores de workflow durables como Temporal persisten esto por ti.