💻 Développement

outbox-pattern-guide

Implémentation du pattern Outbox et Saga pour garantir la cohérence transactionnelle dans les architectures microservices.

⚡ Installation & lancement en 1 commande

Copiez-collez dans votre terminal : le skill s'installe dans ~/.claude/skills et Claude Code se lance directement dessus.

macOS / Linux
curl -fsSL https://raw.githubusercontent.com/khalilbenaz/claude-skills-collection/main/install.sh | sh -s -- outbox-pattern-guide --launch
Windows (PowerShell)
iex "& { $(iwr -useb https://raw.githubusercontent.com/khalilbenaz/claude-skills-collection/main/install.ps1) } outbox-pattern-guide -Launch"

🚀 Déjà installé ?

claude "/outbox-pattern-guide"

Ou tapez /outbox-pattern-guide dans une session Claude Code, ou décrivez simplement votre besoin — le skill se déclenche automatiquement via le skill-router.

🔑 Déclencheurs automatiques

Le skill s'active automatiquement quand votre demande contient :

outbox patternsaga patterntransaction distribuéecohérence éventuelledual writeevent sourcingtransactional outbox

📦 Installation manuelle

git clone https://github.com/khalilbenaz/claude-skills-collection.git cp -r claude-skills-collection/dev-skills/outbox-pattern-guide ~/.claude/skills/

Source : dev-skills/outbox-pattern-guide

📖 Manuel

Guide du Pattern Outbox & Saga

Workflow

  1. Identifier le problème : dual write, perte de messages, incohérence entre services.
  2. Choisir le pattern : Outbox pour la publication fiable, Saga pour les transactions multi-services.
  3. Concevoir : tables, processus de polling/CDC, compensation.
  4. Implémenter : avec le framework approprié (MassTransit, NServiceBus, custom).

Le problème du Dual Write

❌ PROBLÈME : Dual Write
Service → Save to DB        ✅ Succès
Service → Publish to Broker  ❌ Échec
→ DB mis à jour mais message perdu = incohérence

Pattern Outbox

Principe

✅ SOLUTION : Outbox
Service → Transaction DB {
    Save entity to DB
    Save event to OutboxMessages table
} → Commit atomique

Background Worker → Poll OutboxMessages
    → Publish to Broker
    → Mark as processed

Table Outbox

CREATE TABLE OutboxMessages (
    Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
    EventType NVARCHAR(256) NOT NULL,
    Payload NVARCHAR(MAX) NOT NULL,
    CreatedAt DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
    ProcessedAt DATETIMEOFFSET NULL,
    RetryCount INT NOT NULL DEFAULT 0,
    Error NVARCHAR(MAX) NULL,

    INDEX IX_OutboxMessages_Unprocessed (ProcessedAt, CreatedAt)
        WHERE ProcessedAt IS NULL
);

Implémentation C#

// 1. Sauvegarder l'entité + l'événement dans la même transaction
public async Task CreateOrder(CreateOrderCommand command)
{
    var order = new Order(command);
    var outboxMessage = new OutboxMessage
    {
        EventType = nameof(OrderCreated),
        Payload = JsonSerializer.Serialize(new OrderCreated
        {
            OrderId = order.Id,
            CustomerId = command.CustomerId,
            Amount = command.Amount
        })
    };

    _context.Orders.Add(order);
    _context.OutboxMessages.Add(outboxMessage);
    await _context.SaveChangesAsync(); // Transaction atomique
}

// 2. Worker qui publie les messages en attente
public class OutboxProcessor : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var messages = await _context.OutboxMessages
                .Where(m => m.ProcessedAt == null)
                .OrderBy(m => m.CreatedAt)
                .Take(100)
                .ToListAsync(ct);

            foreach (var message in messages)
            {
                try
                {
                    await _bus.Publish(message.EventType, message.Payload);
                    message.ProcessedAt = DateTimeOffset.UtcNow;
                }
                catch (Exception ex)
                {
                    message.RetryCount++;
                    message.Error = ex.Message;
                }
            }

            await _context.SaveChangesAsync(ct);
            await Task.Delay(TimeSpan.FromSeconds(5), ct);
        }
    }
}

Avec MassTransit (Outbox intégré)

// Configuration automatique de l'outbox
builder.Services.AddMassTransit(x =>
{
    x.AddEntityFrameworkOutbox<AppDbContext>(o =>
    {
        o.UseSqlServer();
        o.UseBusOutbox();
        o.QueryDelay = TimeSpan.FromSeconds(5);
    });

    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.ConfigureEndpoints(context);
    });
});

Pattern Saga

Saga Chorégraphiée

Order Service → OrderCreated
    → Payment Service → PaymentProcessed
        → Inventory Service → InventoryReserved
            → Notification Service → OrderConfirmed

Si échec à n'importe quelle étape :
    → Compensation en sens inverse

Saga Orchestrée (recommandée)

public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
    public OrderSaga()
    {
        InstanceState(x => x.CurrentState);

        Event(() => OrderSubmitted, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentProcessed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentFailed, x => x.CorrelateById(m => m.Message.OrderId));

        Initially(
            When(OrderSubmitted)
                .Then(context =>
                {
                    context.Saga.OrderId = context.Message.OrderId;
                    context.Saga.Amount = context.Message.Amount;
                })
                .Publish(context => new ProcessPayment
                {
                    OrderId = context.Saga.OrderId,
                    Amount = context.Saga.Amount
                })
                .TransitionTo(AwaitingPayment)
        );

        During(AwaitingPayment,
            When(PaymentProcessed)
                .Publish(context => new ReserveInventory
                {
                    OrderId = context.Saga.OrderId
                })
                .TransitionTo(AwaitingInventory),

            When(PaymentFailed)
                .Publish(context => new CancelOrder
                {
                    OrderId = context.Saga.OrderId,
                    Reason = "Payment failed"
                })
                .TransitionTo(Cancelled)
                .Finalize()
        );
    }
}

Choisir entre Outbox et Saga

CritèreOutboxSaga
PortéeUn seul serviceMulti-services
ComplexitéFaibleÉlevée
UsagePublication fiable d'événementsTransaction distribuée
CompensationNon nécessaireObligatoire
LatenceLégère (polling)Variable

Règles