FastAPI Hexagonal Code source

08. Transactions, erreurs & câblage

Trois préoccupations qui traversent toutes les couches. Elles se traitent une fois, au bon endroit.

TL;DR — La transaction entoure le use case, pas le repository ; chaque adaptateur entrant fournit cette frontière. Les erreurs métier et les erreurs techniques ont deux hiérarchies distinctes, mappées différemment en HTTP. La composition root est le point où les implémentations concrètes sont assemblées avec les abstractions qu'elles réalisent.

1. La frontière transactionnelle#

La règle, correctement formulée#

Qui décide du commit / rollback ? Pas le repository. Sinon un use case qui fait deux écritures ne pourrait pas les rendre atomiques.

La formulation qu'on lit souvent — « la transaction, c'est la requête HTTP » — est trompeuse : elle est vraie de l'API, et fausse dès qu'un autre canal existe. La bonne formulation est :

La transaction entoure le use case. Chaque adaptateur entrant fournit cette frontière avec les moyens de son canal.

Adaptateur entrant Ouvre la frontière transactionnelle Use case Repositories Succès ? API · CLI · worker mutent la session, ne committent jamais commit rollback + propagation oui non

Les trois implémentations de ce projet#

Même principe, trois déclencheurs :

# 1. API — presentation/api/dependencies/session.py : une transaction par requête
async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
    session_factory = request.app.state.session_factory
    async with session_factory() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
# 2. CLI — presentation/cli/app.py : une transaction par commande
@asynccontextmanager
async def transactional_session() -> AsyncIterator[AsyncSession]:
    ...
    async with session_factory() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
# 3. Worker — presentation/worker/__main__.py : une transaction par message
async with session_factory() as session:
    use_case = NotifyTaskShared(...)
    try:
        await process_message(incoming, use_case, dlx)
        await session.commit()
    except Exception:
        await session.rollback()
        raise

Ces trois canaux fournissent une session au traitement, mais le moment de l’accusé de succès diffère. L’API actuelle committe après la réponse ; le CLI après la commande ; le worker acquitte dans process_message avant le commit du wrapper. Ce dernier ne fait actuellement que des lectures SQL. Pour un consumer qui écrit, le commit doit réussir avant l’ACK, puis le traitement doit tolérer une redélivrance.

ConceptCe que dit le principeCe que fait ce projet
Frontière transactionnelleEntoure l'unité de travail (le use case)Fournie par chaque adaptateur entrant : dépendance FastAPI, context manager CLI, wrapper de message
Gestion transactionnelleUne responsabilité à situer explicitementRepositories sans commit + session injectée ≈ Unit of Work léger

Ce que ce choix te donne#

Un use case peut enchaîner plusieurs écritures dans une seule transaction atomique, sans le savoir. Il ne connaît ni commit, ni rollback, ni même AsyncSession — il ne voit que des ports.

C'est une forme légère d'Unit of Work : le pattern où une unité de travail regroupe les modifications et les valide d'un bloc. D'autres modèles existent — Unit of Work explicite passé au use case, décorateur @transactional, transaction script — et sont tout aussi défendables. Ce qui compte est que la responsabilité transactionnelle soit située et unique, pas la forme exacte.

Quand committer : avant ou après la réponse ?#

Les trois implémentations ci-dessus committent toutes dans la fermeture de la frontière. Pour la CLI et le worker, c'est sans ambiguïté : la commande ou le message est fini. Pour une requête HTTP, c'est un vrai embranchement.

A. Commit avant la réponseB. Commit après la réponse
Câblage typiquecommit explicite dans le chemin du handler, middleware qui committe, ou dépendance dont la sortie est calée sur la fonctiondépendance yield dont la fermeture tourne après l'envoi
Une contrainte violée au commitremonte normalement, tes handlers la mappenttombe trop tard pour changer le statut
Conséquenceun 500 opaque si personne ne la traduitune réponse positive pour une transaction annulée

Dans FastAPI, une dépendance yield exécute son code de sortie après l'envoi de la réponse par défaut : c'est la stratégie B. FastAPI documente un paramètre scope sur Depends — Depends(get_session, scope="function") fait tourner la sortie juste après la fonction de route, donc avant l'envoi : c'est la stratégie A. Vérifie le comportement de la version que tu utilises plutôt que de le supposer, en t'appuyant sur les scopes FastAPI.

Ce projet choisit A : presentation/api/dependencies/session.py expose Depends(get_session, scope="function"), donc la fermeture et son commit s'exécutent avant l'envoi de la réponse. Les providers qui dépendent de cette session doivent rester compatibles avec ce scope ; une réponse streamée utilise un cycle de vie séparé adapté à la durée du flux.

Pour les commandes, garde cette séquence :

POST · PUT · PATCH · DELETE
  use case
    └── flush là où une contrainte doit être traduite
  COMMIT
    └── puis construction et envoi du 2xx

Un 201 ou un 204 est une promesse : l'écriture est durable. Le flush ne la tient pas à lui seul, parce qu'un flush n'est pas un commit. Il fait remonter les violations que tu avais anticipées, à l'endroit qui sait encore les traduire — c'est son rôle, et il reste utile sous les deux stratégies (ch. 02). Mais une contrainte différée, un échec de sérialisation, un trigger, un timeout ou une connexion coupée échouent quand même au COMMIT, après un flush pourtant réussi.

B reste défendable pour les chemins en lecture seule, et pour les écritures dont la confirmation est explicitement provisoire — un 202 Accepted promet l'acceptation, pas la durabilité. Ce que B ne doit jamais être, c'est le défaut que tu n'as pas choisi.

Les limites à connaître#

2. Les effets externes : le problème du double write#

Tout ce qui précède suppose un monde simple : une transaction, une base, un commit. Le monde réel en sort dès qu'une opération doit aussi envoyer un e-mail, publier un message ou appeler une API tierce.

Le problème, posé précisément#

Une seule opération métier, deux systèmes qui ne partagent aucune transaction :

« créer et notifier » =  écrire en base        (PostgreSQL)
                        + publier un message    (RabbitMQ)

On ne peut pas les rendre atomiques. Il n'existe que deux ordres possibles, et les deux sont cassés :

OrdreCe qui casseRésultat
Publier puis committerLe commit échoue après la publicationMessage fantôme : le worker traite une tâche qui n'existe pas
Committer puis publierLe process meurt entre les deuxMessage perdu : la donnée est là, l'effet n'a jamais eu lieu

C'est le dual write problem, et le point à comprendre est qu'il n'a aucune solution par réordonnancement. Déplacer la publication ne fait que choisir quel type de panne on préfère. Il faut changer de mécanisme.

Ce que fait réellement ShareTask. Il vérifie l’existence d’une tâche déjà persistée, puis publie une notification. Il ne crée ni tâche ni enregistrement de partage : ce n’est donc pas actuellement un double write SQL + broker. Un échec de sa transaction de lecture n’efface pas la tâche. Le problème du tableau apparaît dès qu’un futur use case doit enregistrer une mutation et publier ensemble. La suppression concurrente d’une tâche avant sa lecture par le worker est un autre problème, que l’outbox seule ne résout pas.

L'outbox : rendre l'intention transactionnelle#

L'idée tient en une phrase : on n'écrit pas dans le broker, on écrit dans la base — dans la même transaction que la donnée métier.

CREATE TABLE outbox (
    id            uuid PRIMARY KEY,
    name          text        NOT NULL,   -- le type d'événement
    payload       jsonb       NOT NULL,
    created_at    timestamptz NOT NULL DEFAULT now(),
    published_at  timestamptz              -- NULL tant que non publié
);
CREATE INDEX ON outbox (created_at) WHERE published_at IS NULL;
# Le use case n'appelle plus le broker : il écrit une ligne.
async def execute(self, ...) -> None:
    task = await self._tasks.get(task_id)
    event = ShareTaskNotification(task_id=..., user_ids=..., ...)
    await self._outbox.add(event)      # même session, même transaction
    # le commit de la frontière transactionnelle rend les deux atomiques

Un relais séparé lit ensuite les lignes non publiées, les envoie au broker, et les marque publiées. La donnée et l'intention de publier deviennent atomiques : si la transaction échoue, il n'y a ni tâche partagée, ni ligne d'outbox.

Le coût que l'outbox déplace — il ne le supprime pas#

C'est le point que la plupart des présentations escamotent. Le relais peut mourir après avoir publié et avant d'avoir marqué la ligne. Au redémarrage, il republie.

À elle seule, l'outbox ne fournit pas d'exactly-once de bout en bout

Ce qu'elle donne, c'est une publication at-least-once : « le message peut être perdu ou fantôme » devient « le message arrivera, peut-être plusieurs fois ». C'est un progrès énorme — mais il rend l'idempotence du consommateur obligatoire, pas optionnelle. Un effet observable équivalent à l'exactly-once reste atteignable : déduplication, contrainte d'unicité, clé d'idempotence. C'est le consommateur qui le construit — jamais le broker ni l'outbox à eux seuls.

Autrement dit : adopter l'outbox sans rendre le consommateur idempotent, c'est échanger une perte silencieuse contre des doublons silencieux. On n'a pas résolu le problème, on l'a déplacé chez le voisin.

Rendre un consommateur idempotent#

Idempotent signifie que rejouer le traitement n’ajoute pas d’effet métier. Il faut préciser aussi la réponse au doublon : Task.complete() lève TaskAlreadyCompleted au deuxième appel. Un consumer ne peut l’acquitter comme un succès que si ce comportement est explicitement voulu.

Pour des effets dans une même base, enregistrer l’identifiant du message et appliquer la mutation dans la même transaction :

# Illustratif : ces ports ne sont pas implémentés dans ce dépôt.
async with uow:
    inserted = await processed.try_insert(message_id)  # UNIQUE + ON CONFLICT DO NOTHING
    if inserted:
        await apply_database_change(event)
    await uow.commit()
# L'adaptateur acquitte après le commit.

En cas de crash avant commit, les deux écritures sont annulées ; après commit mais avant ACK, la redélivrance retrouve la clé et n’applique pas l’effet une seconde fois. La clé doit être stable et conservée assez longtemps.

Un e-mail n’appartient pas à la transaction SQL. Enregistrer « traité » avant l’envoi puis crasher peut perdre l’e-mail définitivement : le rejeu sera filtré. Enregistrer après l’envoi laisse une fenêtre de doublon. Aucune préférence d’ordre ne résout cela. Il faut une clé d’idempotence comprise par le service externe, ou une procédure de rapprochement avec un compromis explicite sur pertes et doublons. SMTP seul n’offre pas cette garantie.

Une clé métier est possible, mais doit identifier une même demande : (task_id, user_id, jour) supprimerait aussi deux partages légitimes le même jour. Le bon choix dépend du besoin, pas du transport.

Comment le relais lit-il l'outbox ?#

MécanismeLatenceCoûtRemarque
Polling (SELECT … WHERE published_at IS NULL)l'intervalletrivialLe choix par défaut ; index partiel obligatoire
LISTEN/NOTIFYimmédiatefaibleLISTEN exige une connexion dédiée hors du pool transactionnel
CDC (Debezium, réplication logique)quasi immédiateélevéUn composant de plus à exploiter ; pertinent à grande échelle

Un relais peut interroger périodiquement l’outbox ou utiliser LISTEN sur une connexion PostgreSQL directe et dédiée pour être réveillé. NOTIFY peut être émis depuis une transaction passant par PgBouncer. Les notifications ne sont pas un journal durable : le relais relit toujours l’outbox et prévoit un rattrapage après déconnexion.

Et si plusieurs agrégats sont modifiés ?#

C'est l'autre moitié de la question, et elle a une réponse plus simple.

Dans un monolithe sur une base unique, modifier deux agrégats dans une même transaction fonctionne, sans mécanisme particulier. C'est une dérogation assumée à « un agrégat par transaction » (ch. 04), et elle est parfaitement tenable tant que la base est partagée.

Dès que les agrégats vivent dans deux services, la transaction commune disparaît. Deux familles de réponses :

ApprochePrincipeQuand
Cohérence à termeChacun commit chez lui, un événement propageLe cas courant : l'écart temporaire est tolérable
SagaUne suite d'étapes locales, avec une compensation par étapeQuand l'échec d'une étape tardive doit défaire les précédentes

Une saga coordonne des transactions locales et leurs compensations, lesquelles peuvent elles-mêmes échouer et demander une reprise. L’outbox est un moyen de fiabiliser les publications d’une saga ; ce n’est pas une dépendance obligatoire de toute implémentation de saga.

Quant au 2PC / XA (validation en deux phases) : techniquement il résout le problème, en pratique il est écarté presque partout. Les brokers modernes le supportent mal, il maintient des verrous ouverts à travers plusieurs systèmes, et un coordinateur qui tombe laisse des transactions en suspens qu'il faut résoudre à la main.

En a-t-on vraiment besoin ?#

Deux questions suffisent à trancher, et elles ne portent pas sur la technique :

  1. Que se passe-t-il si ce message est perdu ?
  2. Que se passe-t-il si ce message arrive deux fois ?
PerteDoublonRéponse
tolérabletolérablePublication directe. C'est le cas de ce projet.
tolérableinacceptableConsommateur idempotent, sans outbox
inacceptabletolérableOutbox
inacceptableinacceptableOutbox + consommateur idempotent

Pour une notification d'e-mail sur le partage d'une tâche, les deux réponses sont « tolérable » : la ligne 1 s'applique, et l'absence d'outbox est un choix défendable. Pour un débit bancaire, la ligne 4 s'impose.

Une troisième option, souvent oubliée et parfois la meilleure : la réconciliation périodique. Un travail nocturne qui compare les deux systèmes et rattrape les écarts coûte bien moins cher qu'un outbox, et suffit quand la latence de correction est acceptable.

3. Deux familles d'erreurs#

Métier ≠ technique#

Le domaine lève des exceptions métier ; il ignore HTTP. Mais tout n'est pas métier : une base injoignable, un timeout SMTP, un broker down sont des échecs techniques. Les confondre conduit à renvoyer un 400 pour une panne réseau — et à faire croire au client que sa requête était fautive.

Ce projet maintient donc deux hiérarchies parallèles :

ERREURS MÉTIER · domain/shared/ ERREURS TECHNIQUES · application/shared/ DomainError → 400 ValidationError → 422 NotFoundError → 404 ConflictError → 409 InfrastructureError → 503 EmailSendError InvalidEmail EmptyTitle TaskNotFound UserNotFound EmailAlreadyUsed journalisées détail masqué au client non journalisées : c'est un fonctionnement normal

Le critère de tri est simple :

Le client peut-il corriger sa requête ? Oui → erreur métier (4xx). Non, c'est une dépendance qui est en panne → erreur technique (5xx).

Le mapping, une fois pour toutes#

L'astuce qui évite un handler par exception : mapper les bases sémantiques.

# presentation/api/error_handlers.py
@app.exception_handler(NotFoundError)
async def _not_found(_, exc): return _error(404, str(exc))

@app.exception_handler(ConflictError)
async def _conflict(_, exc): return _error(409, str(exc))

# + ValidationError → 422, DomainError → 400 (repli)

@app.exception_handler(InfrastructureError)
async def _service_unavailable(_, exc):
    logger.error("Dépendance technique indisponible : %s", exc, exc_info=exc)
    return _error(503, "Une dépendance technique est indisponible.")

Quatre détails qui comptent :

  1. Un nouveau bounded context est couvert sans toucher ce fichier. Il suffit que ses exceptions héritent de la bonne base : FastAPI remonte la hiérarchie de classes pour trouver le handler.
  2. 503, pas 500. Une dépendance externe indisponible est un problème transitoire et extérieur, pas une anomalie du service.
  3. On journalise les erreurs techniques, pas les erreurs métier. Un 404 est un fonctionnement normal ; polluer les logs avec ferait perdre les vrais incidents.
  4. On ne renvoie pas le détail technique au client. Le message d'une InfrastructureError peut contenir un hôte, un port, une trace — il reste dans les logs.

Et une règle de discipline qui découle du reste : jamais de HTTPException en dehors de presentation/. Lever un code HTTP dans un use case, c'est décider du protocole depuis une couche qui n'est pas censée le connaître — et casser immédiatement le CLI et le worker.

Où logger ?#

Dans ce projet, on évite les logs dans le domaine et on journalise aux frontières, où le contexte technique est disponible. Cela n’en fait pas une fonction pure au sens strict : les factories génèrent des identifiants et lisent l’horloge. Une horloge ou un générateur injectés seraient utiles pour maîtriser ces entrées dans des tests plus exigeants.

4. La composition root#

Quelque part, il faut relier le port abstrait (TaskRepository) à son implémentation (SqlAlchemyTaskRepository). Ce point de câblage s'appelle la composition root : le point où les implémentations concrètes sont assemblées avec les abstractions qu'elles réalisent.

Formulation à ne pas caricaturer On lit souvent « le seul endroit qui connaît les deux mondes ». C'est faux au pied de la lettre — SqlAlchemyTaskRepository connaît évidemment TaskRepository (il en hérite) et SQLAlchemy : un adaptateur n'est pas ignorant de ce qu'il adapte. Ce que la composition root détient d'unique, c'est la décision d'association : « pour cette exécution, ce port sera servi par cet adaptateur-là ». C'est cette décision, et elle seule, qui doit rester en un point unique — sinon elle se disperse en imports concrets un peu partout, et plus rien n'est substituable.

Dans ce projet, c'est le package presentation/api/dependencies/, via l'injection de dépendances de FastAPI :

# dependencies/repositories.py — port ← adaptateur
def get_task_repository(session: SessionDep) -> TaskRepository:
    return SqlAlchemyTaskRepository(session)

# dependencies/task.py — use case ← ses ports
def get_create_task(tasks: TaskRepositoryDep, users: UserRepositoryDep) -> CreateTask:
    return CreateTask(tasks, users)

Son organisation est délibérée :

Ce n'est pas la seule organisation possible : d'autres projets placent la composition root dans un package bootstrap/ ou container/ à la racine, en particulier quand plusieurs adaptateurs driving doivent la partager. Ici, le CLI et le worker construisent leurs dépendances à la main — c'est acceptable à cette échelle, et ça deviendrait une duplication à surveiller si les use cases câblés se multipliaient.

C'est aussi ce qui rend les tests faciles : substituer un adaptateur ne touche à rien d'autre. Les tests d'intégration remplacent get_session par une dépendance équivalente pointant sur la base de test, et injectent un publisher inerte à la place de RabbitMQ — le reste du câblage reste authentique.

À retenir#

À toi de jouer#

  1. Un futur use case crée une tâche puis publie une notification avant le commit. Décris les deux courses possibles avec le worker et explique ce que l’outbox corrige. Pourquoi ShareTask, tel qu’il est écrit aujourd’hui, n’est-il pas cet exemple ?
  2. Une EmailSendError remonte jusqu'à l'API. Le client reçoit un 503. Est-ce le bon code si l'adresse du destinataire était simplement mal formée — et où fallait-il traiter ce cas ?
  3. On ajoute un contexte project avec ProjectNotFound(NotFoundError) et ProjectArchived(ConflictError). Combien de lignes faut-il modifier dans error_handlers.py ? Pourquoi ?

→ Corrigés