Skip to content
Learni
View all tutorials
Architecture Logicielle

Comment implémenter le Outbox Pattern en 2026

Introduction

Le Outbox Pattern résout le problème de la dual-write dans les architectures orientées événements. Lorsqu'une transaction métier modifie l'état et doit publier un événement, un échec entre les deux opérations crée une incohérence. Le pattern stocke l'événement dans une table outbox au sein de la même transaction ACID, puis un processus externe le publie de manière fiable vers le broker. Cette approche garantit au moins une fois la livraison tout en maintenant la cohérence des données. En 2026, avec les systèmes distribués et les exigences de haute disponibilité, ce pattern est devenu indispensable pour les équipes produisant des microservices critiques.

Prérequis

  • Node.js 20+ et TypeScript 5.4+
  • NestJS 10+
  • Prisma 5.20+
  • RabbitMQ ou tout broker compatible AMQP
  • Connaissances solides des transactions et du pattern Saga

Schéma Prisma de la table Outbox

prisma/schema.prisma
model OutboxEvent {
  id            String   @id @default(uuid())
  aggregateType String
  aggregateId   String
  eventType     String
  payload       Json
  createdAt     DateTime @default(now())
  processedAt   DateTime?
  retryCount    Int      @default(0)
  
  @@index([processedAt])
  @@index([createdAt])
}

La table outbox capture chaque événement avec son agrégat, son type et son payload JSON. L'index sur processedAt permet au processeur de récupérer rapidement les événements non traités.

Service de persistance atomique

src/outbox/outbox.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class OutboxService {
  constructor(private readonly prisma: PrismaService) {}

  async saveEvent<T>(
    tx: any,
    aggregateType: string,
    aggregateId: string,
    eventType: string,
    payload: T
  ) {
    return tx.outboxEvent.create({
      data: {
        aggregateType,
        aggregateId,
        eventType,
        payload: payload as any,
      },
    });
  }
}

Ce service reçoit une transaction Prisma et insère l'événement dans la même transaction que les opérations métier, garantissant l'atomicité.

Transaction métier avec Outbox

src/orders/orders.service.ts
async createOrder(dto: CreateOrderDto) {
  return this.prisma.$transaction(async (tx) => {
    const order = await tx.order.create({ data: dto });
    
    await this.outboxService.saveEvent(
      tx,
      'Order',
      order.id,
      'OrderCreated',
      { orderId: order.id, total: dto.total }
    );
    
    return order;
  });
}

La création de la commande et l'insertion de l'événement se font dans une seule transaction. Si l'insertion échoue, la commande est annulée.

Processeur d'événements (polling)

src/outbox/outbox.processor.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AmqpService } from '../amqp/amqp.service';

@Injectable()
export class OutboxProcessor implements OnModuleInit {
  private isRunning = false;

  constructor(
    private prisma: PrismaService,
    private amqp: AmqpService
  ) {}

  onModuleInit() {
    this.startPolling();
  }

  private async startPolling() {
    this.isRunning = true;
    while (this.isRunning) {
      const events = await this.prisma.outboxEvent.findMany({
        where: { processedAt: null },
        orderBy: { createdAt: 'asc' },
        take: 50,
      });

      for (const event of events) {
        await this.publishWithRetry(event);
      }
      await new Promise(r => setTimeout(r, 500));
    }
  }

  private async publishWithRetry(event: any) {
    try {
      await this.amqp.publish('orders', event.eventType, event.payload);
      await this.prisma.outboxEvent.update({
        where: { id: event.id },
        data: { processedAt: new Date() },
      });
    } catch (e) {
      await this.prisma.outboxEvent.update({
        where: { id: event.id },
        data: { retryCount: { increment: 1 } },
      });
    }
  }
}

Le processeur interroge périodiquement les événements non traités et les publie. Les échecs incrémentent le compteur de retry pour une gestion ultérieure.

Configuration du module NestJS

src/outbox/outbox.module.ts
import { Module } from '@nestjs/common';
import { OutboxService } from './outbox.service';
import { OutboxProcessor } from './outbox.processor';

@Module({
  providers: [OutboxService, OutboxProcessor],
  exports: [OutboxService],
})
export class OutboxModule {}

Le module expose le service et lance automatiquement le processeur au démarrage de l'application.

Bonnes pratiques

  • Limitez la taille du payload à 10 Ko maximum et stockez les gros objets dans un stockage objet.
  • Implémentez un mécanisme de dead-letter queue après 5 tentatives.
  • Utilisez des index composites sur (processedAt, createdAt) pour les gros volumes.
  • Ajoutez un champ de corrélation pour le tracing distribué.
  • Testez systématiquement les scénarios de coupure réseau entre base et broker.

Erreurs courantes à éviter

  • Oublier de passer la transaction au service outbox, rompant l'atomicité.
  • Ne pas gérer les événements dupliqués côté consommateur (idempotence).
  • Utiliser un seul processeur sans verrouillage pour les déploiements multi-instances.
  • Stocker des données sensibles en clair dans le payload JSON.

Pour aller plus loin

Approfondissez l'implémentation avec le Change Data Capture (CDC) et Debezium dans notre formation Architecture événementielle avancée.