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
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
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
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)
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
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.