Una operación de negocio que toca varios servicios plantea una pregunta que SQL resuelve desde hace cincuenta años dentro de una sola base de datos: ¿qué pasa cuando un paso tiene éxito y el siguiente falla? Mientras todo vive en la misma base, BEGIN ... ROLLBACK basta. En cuanto llamas a un servicio externo, una API de terceros u otra base de datos, esa red de seguridad desaparece.

El patrón Saga responde a esa pregunta. En lugar de intentar una transacción ACID imposible, divide la operación en pasos locales, cada uno acompañado de una transacción compensatoria que sabe deshacer su efecto. Si el paso 4 falla, las compensaciones de los pasos 1, 2 y 3 se reproducen en orden inverso.

Este artículo abre una serie sobre patrones de arquitectura distribuida. Empezamos con el más estructurante, el que cambia la forma de pensar una operación de negocio en cuanto cruza la frontera de un servicio. Pertenece a la misma familia que la capa anti-corrupción: aislar los efectos y mantener el código de negocio al mando de sus fronteras.

El problema: no hay rollback más allá de la base

Imaginemos el checkout de una tienda en línea. La operación encadena cuatro pasos:

  1. Crear el pedido en la base de datos
  2. Reservar los artículos en el servicio de stock
  3. Cobrar la tarjeta del cliente con Stripe
  4. Planificar el envío con el transportista

Con una sola base PostgreSQL, envolverías todo en una transacción y cualquier fallo lo revertiría. Pero el paso 3 llama a la API de Stripe. Una vez aceptado el cargo, ningún ROLLBACK SQL puede deshacer lo ocurrido fuera. El cliente ha sido cobrado, el webhook de Stripe va a volver, el evento ha salido del perímetro.

Si el paso 4 falla, te quedas con un pedido creado, stock reservado, un cliente cobrado y ningún envío planificado. Sin un plan explícito, ese pedido existirá a medias para siempre.

El principio: compensar en lugar de hacer rollback

La Saga parte de una observación simple: cada paso que modifica un sistema externo debe saber deshacer su propio efecto. Deshacer no es lo mismo que hacer rollback. Reembolsar un cargo de Stripe significa llamar a una API de refund, que deja una traza en el lado de Stripe, emite un evento contable y aparece en el extracto del cliente. Es una operación de negocio explícita, no un borrado invisible.

Una Saga es entonces una secuencia de pasos, donde cada paso S_i está asociado a una compensación C_i. Si falla en el paso n, se ejecutan C_{n-1}, C_{n-2}, ..., C_1 en orden inverso. El invariante a mantener: cada compensación debe ser idempotente, y el sistema debe poder retomar donde se quedó tras un crash.

Coreografía u orquestación

Coexisten dos estilos de implementación.

La coreografía se apoya en eventos. Cada servicio escucha lo que los demás publican y reacciona. PedidoCreado dispara el módulo de stock, que publica StockReservado, que dispara el módulo de pago, etc. Sin director de orquesta. Es elegante mientras el flujo se mantiene pequeño. Más allá de tres o cuatro pasos, seguir lo que pasa se vuelve complicado: la lógica de la Saga queda dispersa en tantos servicios como pasos haya, y depurar un fallo obliga a reconstruir el hilo a partir de los logs.

La orquestación centraliza la lógica en un único componente, un SagaOrchestrator, que conoce todos los pasos, los lanza en orden y dirige las compensaciones en caso de fallo. Más explícito, más testable, mejor adaptado a flujos complejos. Para un checkout que toca stock, pago y envío, es casi siempre la elección correcta.

Una orquestación mínima en Python

Aquí tienes una versión depurada de un orquestador Saga. Cada paso implementa execute y compensate.

from abc import ABC, abstractmethod
from dataclasses import dataclass


class SagaStep(ABC):
    @abstractmethod
    def execute(self, context: dict) -> None: ...

    @abstractmethod
    def compensate(self, context: dict) -> None: ...


@dataclass
class SagaFailed(Exception):
    step: str
    cause: Exception


class SagaOrchestrator:
    def __init__(self, steps: list[SagaStep]) -> None:
        self.steps = steps

    def run(self, context: dict) -> None:
        executed: list[SagaStep] = []
        current: SagaStep | None = None
        try:
            for step in self.steps:
                current = step
                step.execute(context)
                executed.append(step)
        except Exception as exc:
            for done in reversed(executed):
                try:
                    done.compensate(context)
                except Exception:
                    # log y continuar: una compensación que falla
                    # no debe bloquear las siguientes
                    continue
            raise SagaFailed(step=type(current).__name__, cause=exc) from exc

Y los pasos del pedido e-commerce:

class CrearPedidoStep(SagaStep):
    def execute(self, context):
        pedido = Pedido.objects.create(**context["datos"])
        context["pedido_id"] = pedido.id

    def compensate(self, context):
        Pedido.objects.filter(id=context["pedido_id"]).update(
            estado="cancelado"
        )


class CobrarPagoStep(SagaStep):
    def execute(self, context):
        charge = stripe.Charge.create(
            amount=context["monto"],
            currency="eur",
            customer=context["cliente_id"],
            idempotency_key=f"order-{context['pedido_id']}",
        )
        context["charge_id"] = charge.id

    def compensate(self, context):
        stripe.Refund.create(charge=context["charge_id"])


class PlanificarEnvioStep(SagaStep):
    def execute(self, context):
        envio = shipping_api.create(context["pedido_id"])
        context["envio_id"] = envio.id

    def compensate(self, context):
        shipping_api.cancel(context["envio_id"])


saga = SagaOrchestrator([
    CrearPedidoStep(),
    CobrarPagoStep(),
    PlanificarEnvioStep(),
])
saga.run({"datos": {...}, "monto": 4990, "cliente_id": "cus_..."})

Si PlanificarEnvioStep.execute lanza una excepción, el orquestador llama a CobrarPagoStep.compensate y después a CrearPedidoStep.compensate. Stripe reembolsa al cliente, el pedido pasa a cancelado. El sistema permanece coherente.

Las trampas que se descubren en producción

El código anterior funciona en el caso ideal. Tres problemas aparecen en cuanto se despliega de verdad.

La idempotencia es obligatoria. Una compensación puede ser re-ejecutada. Si el proceso revienta después de compensar el paso 2 pero antes de marcar la Saga como terminada, el reinicio volverá a intentar la compensación. stripe.Refund.create debe poder llamarse dos veces sin reembolsar dos veces. Por eso Stripe expone un idempotency_key en sus endpoints: úsalo de forma sistemática, tanto en charges como en refunds.

El fallo de una compensación es un caso real. La API de Stripe o el servicio de envíos pueden estar caídos cuando quieres compensar. La estrategia razonable: reintentos con backoff, y si tras varios intentos la compensación sigue fallando, se mueve la Saga a un estado compensation_failed que alerta a un humano. Una compensación que falla en silencio deja el sistema en un estado corrupto.

El estado de la Saga debe sobrevivir al crash del proceso. El ejemplo anterior mantiene el estado en memoria. Si el worker muere entre dos pasos, se pierde la traza de lo que se ha hecho. En la práctica, se persiste el estado tras cada paso: un modelo SagaInstance en base, con el estado de cada paso y el contexto serializado. Al reiniciar, se retoma donde se dejó o se lanza la compensación.

El encaje con Django y Celery

Sobre una pila Django más Celery, el patrón se traduce de forma natural. Cada paso se convierte en una tarea Celery, encadenada con la siguiente mediante una tarea execute_step que mantiene el estado en base.

from celery import shared_task
from django.db import transaction


@shared_task(bind=True, max_retries=3)
def execute_step(self, saga_id: int, step_index: int):
    with transaction.atomic():
        saga = SagaInstance.objects.select_for_update().get(id=saga_id)
        step = STEPS[step_index]
        try:
            step.execute(saga.context)
            saga.mark_step_done(step_index)
        except Exception:
            compensate_saga.delay(saga_id, up_to=step_index)
            raise

    if step_index + 1 < len(STEPS):
        execute_step.delay(saga_id, step_index + 1)
    else:
        saga.mark_completed()

El select_for_update evita que un reintento concurrente ejecute el mismo paso dos veces, pero exige una transacción abierta o Django lanza TransactionManagementError. La planificación del siguiente paso (execute_step.delay) queda intencionadamente fuera del bloque atómico, para no emitir una tarea Celery si la transacción acaba en rollback. La compensación es a su vez una tarea Celery, lo que le da automáticamente reintento y resiliencia frente a crashes del worker.

El encaje con el Transactional Outbox

Un paso de Saga a menudo necesita publicar un evento (PedidoCreado, PagoCobrado) para que otras partes del sistema reaccionen. Publicar directamente hacia un broker desde el código del paso plantea un problema clásico: si la publicación tiene éxito y la transacción local falla, se emite un evento que no corresponde a nada. Lo contrario también es cierto.

El patrón Outbox resuelve ese desajuste. El paso escribe el evento en una tabla outbox dentro de la misma transacción SQL que su propia escritura de negocio. Un publisher externo lee esa tabla y emite hacia el broker. Ambos estados quedan coherentes pase lo que pase. Un próximo artículo de la serie detallará este mecanismo.

Cuándo no utilizar una Saga

El patrón tiene un coste: más código, estado a persistir, lógica de compensación que escribir y testear para cada paso. Para un flujo que cabe en una sola base y una sola transacción, es sobreingeniería. Un transaction.atomic de Django hace el trabajo, más simplemente, con garantías ACID reales.

La Saga se vuelve pertinente en cuanto:

  • una operación de negocio toca varios sistemas (bases distintas, APIs externas, colas de mensajes)
  • los pasos no pueden hacerse atómicos entre sí
  • la operación debe poder retomarse tras un crash
  • cada paso tiene una noción de negocio de “deshacer” que tiene sentido

A la inversa, para un flujo puramente local, para una operación idempotente que basta con re-ejecutar tras un fallo, o para un caso donde “no hacer nada” es una compensación aceptable, el patrón Saga es demasiado pesado.

Conclusión

La Saga no reemplaza las transacciones, acepta que ya no son posibles y propone un marco para vivir con ello. Cada paso lleva su propia lógica de anulación, el orquestador garantiza el orden y la persistencia del estado, y el sistema permanece coherente incluso cuando un servicio externo cae en mitad de un flujo.

El patrón se revela cuando empiezas a trazar lo que realmente ocurre en una operación de negocio distribuida. La compensación nunca es gratis, pero es explícita, trazable y auditable, donde un estado corrupto en silencio nunca lo es.