Une opération métier qui touche plusieurs services pose un problème que SQL résout depuis cinquante ans à l’intérieur d’une base unique : que faire quand une étape réussit et que la suivante échoue ? Tant que tout vit dans la même base, BEGIN ... ROLLBACK suffit. Dès qu’on appelle un service externe, une API tierce ou une autre base, ce filet de sécurité disparaît.
Le pattern Saga répond à cette question. Plutôt que de tenter une transaction ACID impossible, il découpe l’opération en étapes locales, chacune accompagnée d’une transaction compensatrice qui sait défaire son effet. Si l’étape 4 échoue, on rejoue en sens inverse les compensations des étapes 1, 2 et 3.
Cet article ouvre une série sur les patterns d’architecture distribuée. On commence par le plus structurant, celui qui change la façon de penser une opération métier dès qu’elle franchit une frontière de service. Il se place dans la même famille que la couche anti-corruption : isoler les effets et garder le code métier maître de ses frontières.
Le problème : pas de rollback au-delà de la base
Imaginons la validation d’une commande dans une boutique en ligne. L’opération enchaîne quatre étapes :
- Créer la commande en base
- Réserver les articles dans le service de stock
- Débiter la carte du client via Stripe
- Planifier l’expédition chez le transporteur
Avec une seule base PostgreSQL, on enroberait le tout dans une transaction et un échec rollback-erait l’ensemble. Mais l’étape 3 appelle l’API Stripe. Une fois le débit accepté, aucun ROLLBACK SQL ne peut annuler ce qui s’est passé à l’extérieur. La carte du client est débitée, le webhook Stripe va revenir, l’événement est sorti du périmètre.
Si l’étape 4 plante, vous vous retrouvez avec une commande créée, du stock réservé, un client débité, et aucune expédition planifiée. Sans plan explicite, cette commande existera à moitié pour toujours.
Le principe : compenser plutôt que rollback
La Saga part d’un constat simple : chaque étape qui modifie un système externe doit savoir comment annuler son propre effet. Annuler n’est pas la même chose que rollback. Rembourser un paiement Stripe, c’est appeler une API de refund, qui laisse une trace côté Stripe, génère un événement comptable, et apparaît sur le relevé du client. C’est une opération métier explicite, pas une suppression invisible.
Une Saga est donc une séquence d’étapes, où chaque étape S_i est associée à une compensation C_i. En cas d’échec à l’étape n, on exécute C_{n-1}, C_{n-2}, ..., C_1 dans l’ordre inverse. L’invariant à tenir : chaque compensation doit être idempotente, et le système doit pouvoir reprendre où il en était après un crash.
Chorégraphie ou orchestration
Deux styles d’implémentation coexistent.
La chorégraphie repose sur des événements. Chaque service écoute ce que les autres publient et réagit. CommandeCreee déclenche le module stock, qui publie StockReserve, qui déclenche le module paiement, etc. Aucun chef d’orchestre. C’est élégant tant que le workflow reste petit. Au-delà de trois ou quatre étapes, suivre ce qui se passe devient compliqué : la logique de la Saga est dispersée dans autant de services qu’il y a d’étapes, et déboguer un échec demande de reconstituer le fil à partir des logs.
L’orchestration centralise la logique dans un composant unique, un SagaOrchestrator, qui connaît toutes les étapes, les déclenche dans l’ordre, et pilote les compensations en cas d’échec. Plus explicite, plus testable, plus adapté aux workflows complexes. Pour la validation d’une commande qui touche stock, paiement et expédition, c’est presque toujours le bon choix.
Une orchestration minimaliste en Python
Voici une version épurée d’un orchestrateur Saga. Chaque étape implémente execute et 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 et continue : on ne veut pas qu'une compensation
# qui plante bloque les suivantes
continue
raise SagaFailed(step=type(current).__name__, cause=exc) from exc
Et les étapes de la commande e-commerce :
class CreerCommandeStep(SagaStep):
def execute(self, context):
commande = Commande.objects.create(**context["donnees"])
context["commande_id"] = commande.id
def compensate(self, context):
Commande.objects.filter(id=context["commande_id"]).update(
statut="annulee"
)
class DebiterPaiementStep(SagaStep):
def execute(self, context):
charge = stripe.Charge.create(
amount=context["montant"],
currency="eur",
customer=context["client_id"],
idempotency_key=f"order-{context['commande_id']}",
)
context["charge_id"] = charge.id
def compensate(self, context):
stripe.Refund.create(charge=context["charge_id"])
class PlanifierExpeditionStep(SagaStep):
def execute(self, context):
shipment = shipping_api.create(context["commande_id"])
context["shipment_id"] = shipment.id
def compensate(self, context):
shipping_api.cancel(context["shipment_id"])
saga = SagaOrchestrator([
CreerCommandeStep(),
DebiterPaiementStep(),
PlanifierExpeditionStep(),
])
saga.run({"donnees": {...}, "montant": 4990, "client_id": "cus_..."})
Si PlanifierExpeditionStep.execute lève une exception, l’orchestrateur appelle DebiterPaiementStep.compensate puis CreerCommandeStep.compensate. Stripe rembourse le client, la commande passe en statut annulee. Le système reste cohérent.
Les pièges qu’on découvre en production
Le code ci-dessus marche dans un cas idéal. Trois problèmes apparaissent dès qu’on le déploie pour de vrai.
L’idempotence est obligatoire. Une compensation peut être rejouée. Si le processus crash après avoir compensé l’étape 2 mais avant de marquer la Saga terminée, le redémarrage va tenter à nouveau la compensation. stripe.Refund.create doit pouvoir être appelée deux fois sans rembourser deux fois. C’est pour ça que Stripe propose une idempotency_key sur ses endpoints : on l’utilise systématiquement, côté charge comme côté refund.
L’échec d’une compensation est un cas réel. L’API Stripe ou le service d’expédition peuvent être indisponibles au moment où l’on veut compenser. La stratégie raisonnable : retry avec backoff, et si après plusieurs tentatives la compensation échoue toujours, on bascule la Saga dans un statut compensation_failed qui alerte un humain. Une compensation qui échoue silencieusement laisse le système dans un état corrompu.
L’état de la Saga doit survivre au crash du processus. L’exemple plus haut garde l’état en mémoire. Si le worker meurt entre deux étapes, on perd la trace de ce qui a été fait. En pratique, on persiste l’état après chaque étape : un modèle SagaInstance en base, avec le statut de chaque étape et le contexte sérialisé. Au redémarrage, on reprend là où on s’était arrêté, ou on lance la compensation.
L’articulation avec Django et Celery
Sur une stack Django plus Celery, le pattern se traduit naturellement. Chaque étape devient une tâche Celery, chaînée avec la suivante par une tâche execute_step qui maintient l’état 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()
Le select_for_update évite qu’une retry concurrente joue la même étape deux fois, mais il exige une transaction ouverte sinon Django lève TransactionManagementError. La planification de l’étape suivante (execute_step.delay) sort volontairement du bloc atomique, pour ne pas émettre une tâche Celery si la transaction est ensuite rollback. La compensation est elle-même une tâche Celery, ce qui lui donne automatiquement le retry et la résilience aux crashs du worker.
L’articulation avec le Transactional Outbox
Une étape de Saga doit souvent publier un événement (CommandeCreee, PaiementDebite) pour que d’autres parties du système réagissent. Publier directement vers un broker depuis le code de l’étape pose un problème classique : si la publication réussit et que la transaction locale échoue, on émet un événement qui ne correspond à rien. L’inverse est aussi vrai.
Le pattern Outbox résout ce désalignement. L’étape écrit l’événement dans une table outbox au sein de la même transaction SQL que sa propre écriture métier. Un publisher externe lit cette table et émet vers le broker. Les deux états restent cohérents quoi qu’il arrive. Un futur article de cette série détaillera ce mécanisme.
Quand ne pas utiliser une Saga
Le pattern a un coût : du code en plus, un état à persister, une logique de compensation à écrire et à tester pour chaque étape. Pour un workflow qui tient dans une seule base et une seule transaction, c’est de la sur-ingénierie. Un transaction.atomic Django fait le travail, plus simplement, avec de vraies garanties ACID.
La Saga devient pertinente dès que :
- une opération métier touche plusieurs systèmes (bases différentes, APIs externes, files de messages)
- les étapes ne peuvent pas être rendues atomiques entre elles
- l’opération doit pouvoir être reprise après crash
- chaque étape a une notion métier de “défaire” qui a du sens
À l’inverse, pour un workflow purement local, pour une opération idempotente qu’on peut simplement rejouer en cas d’échec, ou pour un cas où “ne rien faire” est une compensation acceptable, le pattern Saga est trop lourd.
Conclusion
La Saga ne remplace pas les transactions, elle accepte qu’elles ne sont plus possibles et propose un cadre pour vivre avec. Chaque étape porte sa propre logique d’annulation, l’orchestrateur garantit l’ordre et la persistance de l’état, et le système reste cohérent même quand un service externe lâche au milieu d’un workflow.
Le pattern se révèle quand on commence à tracer ce qui se passe vraiment dans une opération métier distribuée. La compensation n’est jamais gratuite, mais elle est explicite, traçable et auditable, là où un état corrompu silencieux ne l’est jamais.
