Aller au contenu

Le pattern Outbox

Introduction

Dans une application distribuée, une opération métier peut nécessiter à la fois une modification en base de données et l’envoi d’un message à un autre service. Ces deux actions peuvent échouer indépendamment : une commande peut être enregistrée sans que le service de facturation en soit informé. Le pattern Transactional Outbox permet d’enregistrer la modification métier et le message à transmettre dans une même transaction locale, puis de transmettre ce message en arrière-plan.

Le problème : deux écritures indépendantes

Imaginons un service OrdersService qui enregistre une commande puis publie un événement OrderCreated destiné à BillingService.

// Exemple volontairement fragile.
db.Orders.Add(order);
await db.SaveChangesAsync(cancellationToken);
await publisher.PublishAsync(orderCreated, cancellationToken);

Si l’application s’arrête après l’enregistrement, ou si le destinataire est indisponible, la commande existe mais son événement peut être perdu. Inverser les opérations ne résout pas le problème : le destinataire pourrait recevoir un événement concernant une commande dont l’enregistrement échoue ensuite.

Une transaction EF Core autour de ces deux appels ne rend pas automatiquement l’envoi réseau transactionnel. Le système externe ne participe pas à la transaction de la base de données.

Le principe

Le service enregistre, dans la même transaction :

  • les données métier ;
  • une ligne dans une table OutboxMessages, contenant le message à transmettre.

Un processus de publication lit ensuite les messages en attente, les transmet et les marque comme envoyés après confirmation. Si la transmission échoue, le message reste disponible pour une nouvelle tentative.

Le mot Outbox désigne une boîte de sortie. La table matérialise une intention durable d’envoyer un message ; elle ne constitue pas, à elle seule, un broker de messages.

Cette approche est décrite par Microsoft dans sa présentation du pattern Transactional Outbox.

Les garanties et leurs limites

L’atomicité concerne les données métier et le message enregistré dans la base du service émetteur. Elle ne concerne pas l’ensemble du traitement réparti entre plusieurs services.

Situation Comportement attendu
Échec de l’enregistrement local Ni la commande ni le message ne sont validés.
Arrêt après validation de la transaction Le message reste dans l’Outbox et sera repris au redémarrage.
Destinataire indisponible La commande reste enregistrée ; l’envoi sera retenté.
Arrêt après réception distante, avant marquage local Le même message peut être envoyé à nouveau.

L’Outbox prépare donc une livraison au moins une fois, sous réserve que les services redémarrent, que les données soient conservées et que les erreurs puissent être résolues. Elle ne garantit pas une livraison exactement une fois.

Le destinataire doit pouvoir traiter plusieurs réceptions du même message sans répéter son effet métier. Cette propriété s’appelle l’idempotence.

Un exemple complet avec ASP.NET Core et EF Core

Le scénario

Deux applications utilisent chacune leur base SQLite :

  • OrdersService crée une commande et son message Outbox dans orders.db ;
  • un BackgroundService transmet le message par HTTP ;
  • BillingService crée une facture et mémorise l’identifiant du message dans une table Inbox de billing.db.

Le transport HTTP permet d’observer le mécanisme sans installer de broker. Avec RabbitMQ ou Azure Service Bus, le principe de stockage local reste le même, mais le code de transmission et d’acquittement change.

L’exemple vise .NET 8 et EF Core 8. Il utilise une seule instance de chaque application, et un seul worker Outbox. SQLite et EnsureCreatedAsync facilitent la démonstration ; une application en production utilisera un schéma géré par migrations et une coordination adaptée à son déploiement.

Les montants sont exprimés en centimes d’euros dans un long, afin de conserver un exemple simple sans calculs en virgule flottante.

1. Créer les projets

Avec le SDK .NET 8 installé :

mkdir OutboxDemo
cd OutboxDemo

dotnet new web -n OrdersService -f net8.0
dotnet new web -n BillingService -f net8.0

dotnet add OrdersService package Microsoft.EntityFrameworkCore.Sqlite --version '8.0.*'
dotnet add BillingService package Microsoft.EntityFrameworkCore.Sqlite --version '8.0.*'

Le joker sélectionne un correctif EF Core 8 disponible. Pour un dépôt reproductible, fixer ensuite la version exacte et conserver les fichiers de verrouillage NuGet.

2. Implémenter le service de commandes

Remplacer intégralement OrdersService/Program.cs par :

using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<OrdersDbContext>(options =>
    options.UseSqlite("Data Source=orders.db"));

builder.Services.AddHttpClient("billing", client =>
{
    client.BaseAddress = new Uri("http://localhost:5002");
    client.Timeout = TimeSpan.FromSeconds(5);
});

builder.Services.AddHostedService<OutboxWorker>();

var app = builder.Build();

await using (var scope = app.Services.CreateAsyncScope())
{
    var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
    await db.Database.EnsureCreatedAsync();
}

app.MapPost("/orders", async (
    CreateOrderRequest request,
    OrdersDbContext db,
    CancellationToken cancellationToken) =>
{
    if (request.TotalInCents <= 0)
        return Results.BadRequest("Le montant doit être positif.");

    var order = new Order
    {
        Id = Guid.NewGuid(),
        TotalInCents = request.TotalInCents
    };

    var messageId = Guid.NewGuid();
    var createdEvent = new OrderCreated(
        messageId,
        order.Id,
        order.TotalInCents,
        DateTime.UtcNow);

    db.Orders.Add(order);
    db.OutboxMessages.Add(new OutboxMessage
    {
        Id = messageId,
        Type = "order-created.v1",
        Payload = JsonSerializer.Serialize(createdEvent),
        CreatedAtUtc = createdEvent.OccurredAtUtc,
        NextAttemptAtUtc = DateTime.UtcNow
    });

    // Un seul SaveChanges : commande et message sont atomiques.
    await db.SaveChangesAsync(cancellationToken);

    return Results.Created($"/orders/{order.Id}", new
    {
        order.Id,
        order.TotalInCents,
        MessageId = messageId
    });
});

app.MapGet("/orders/{id:guid}", async (
    Guid id, OrdersDbContext db, CancellationToken cancellationToken) =>
{
    var order = await db.Orders.AsNoTracking()
        .SingleOrDefaultAsync(x => x.Id == id, cancellationToken);

    return order is null ? Results.NotFound() : Results.Ok(order);
});

// Endpoints d'observation destinés à la démonstration.
app.MapGet("/outbox", async (
    OrdersDbContext db, CancellationToken cancellationToken) =>
    await db.OutboxMessages.AsNoTracking()
        .OrderBy(x => x.CreatedAtUtc)
        .ToListAsync(cancellationToken));

await app.RunAsync();

public sealed record CreateOrderRequest(long TotalInCents);

public sealed record OrderCreated(
    Guid MessageId,
    Guid OrderId,
    long TotalInCents,
    DateTime OccurredAtUtc);

public sealed class Order
{
    public Guid Id { get; set; }
    public long TotalInCents { get; set; }
}

public sealed class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = "";
    public string Payload { get; set; } = "";
    public DateTime CreatedAtUtc { get; set; }
    public DateTime NextAttemptAtUtc { get; set; }
    public DateTime? SentAtUtc { get; set; }
    public int Attempts { get; set; }
    public string? LastError { get; set; }
}

public sealed class OrdersDbContext(
    DbContextOptions<OrdersDbContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>().HasKey(x => x.Id);
        modelBuilder.Entity<OutboxMessage>().HasKey(x => x.Id);
        modelBuilder.Entity<OutboxMessage>()
            .HasIndex(x => new { x.SentAtUtc, x.NextAttemptAtUtc });
    }
}

public sealed class OutboxWorker(
    IServiceScopeFactory scopeFactory,
    IHttpClientFactory httpClientFactory,
    ILogger<OutboxWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                try
                {
                    await DispatchBatchAsync(stoppingToken);
                }
                catch (OperationCanceledException)
                    when (stoppingToken.IsCancellationRequested)
                {
                    throw;
                }
                catch (Exception exception)
                {
                    // Une erreur de lecture ou de sauvegarde ne tue pas le worker.
                    logger.LogError(exception, "Erreur du worker Outbox.");
                }

                await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
            }
        }
        catch (OperationCanceledException)
            when (stoppingToken.IsCancellationRequested)
        {
            // Arrêt normal de l'application.
        }
    }

    private async Task DispatchBatchAsync(CancellationToken cancellationToken)
    {
        // Le worker est singleton ; le DbContext doit avoir un scope propre.
        await using var scope = scopeFactory.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
        var client = httpClientFactory.CreateClient("billing");
        var now = DateTime.UtcNow;

        var messages = await db.OutboxMessages
            .Where(x => x.SentAtUtc == null && x.NextAttemptAtUtc <= now)
            .OrderBy(x => x.CreatedAtUtc)
            .ThenBy(x => x.Id)
            .Take(20)
            .ToListAsync(cancellationToken);

        foreach (var message in messages)
        {
            try
            {
                if (message.Type != "order-created.v1")
                    throw new InvalidOperationException("Type de message inconnu.");

                using var content = new StringContent(
                    message.Payload, Encoding.UTF8, "application/json");

                using var response = await client.PostAsync(
                    "/events/order-created", content, cancellationToken);

                response.EnsureSuccessStatusCode();

                // Le destinataire confirme après sa transaction locale.
                message.SentAtUtc = DateTime.UtcNow;
                message.LastError = null;
            }
            catch (OperationCanceledException)
                when (cancellationToken.IsCancellationRequested)
            {
                throw;
            }
            catch (Exception exception)
            {
                message.Attempts++;
                var delaySeconds = Math.Min(
                    60, Math.Pow(2, Math.Min(message.Attempts, 6)));
                message.NextAttemptAtUtc = DateTime.UtcNow
                    .AddSeconds(delaySeconds);
                message.LastError = exception.Message.Length <= 1000
                    ? exception.Message
                    : exception.Message[..1000];

                logger.LogWarning(exception,
                    "Échec de l'envoi du message {MessageId}.", message.Id);
            }

            // Hors du try d'envoi : si cette sauvegarde échoue après réception
            // distante, le message reste à reprendre et peut être renvoyé.
            await db.SaveChangesAsync(cancellationToken);
        }
    }
}

Avec un fournisseur relationnel comme SQLite, un appel à SaveChangesAsync enregistre ses modifications dans une transaction par défaut. Il suffit donc ici d’ajouter la commande et le message au même contexte, puis de les sauvegarder ensemble. Si plusieurs appels de sauvegarde sont nécessaires pour une même opération, une transaction explicite doit les englober. Voir la documentation EF Core sur les transactions.

La réponse 201 Created confirme l’enregistrement de la commande ; elle ne confirme pas encore la création de la facture. Le traitement distant est différé.

3. Implémenter le service de facturation et son Inbox

L’Inbox est une table de messages déjà traités. Son identifiant doit être enregistré dans la même transaction que l’effet métier. Une simple liste d’identifiants en mémoire ne résisterait pas au redémarrage.

Remplacer intégralement BillingService/Program.cs par :

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<BillingDbContext>(options =>
    options.UseSqlite("Data Source=billing.db"));

var app = builder.Build();

await using (var scope = app.Services.CreateAsyncScope())
{
    var db = scope.ServiceProvider.GetRequiredService<BillingDbContext>();
    await db.Database.EnsureCreatedAsync();
}

app.MapPost("/events/order-created", async (
    OrderCreated message,
    BillingDbContext db,
    CancellationToken cancellationToken) =>
{
    if (message.MessageId == Guid.Empty ||
        message.OrderId == Guid.Empty || message.TotalInCents <= 0)
    {
        return Results.BadRequest("Message invalide.");
    }

    if (await db.InboxMessages.AsNoTracking().AnyAsync(
        x => x.Id == message.MessageId, cancellationToken))
    {
        return Results.Ok(new { AlreadyProcessed = true });
    }

    db.InboxMessages.Add(new InboxMessage
    {
        Id = message.MessageId,
        ProcessedAtUtc = DateTime.UtcNow
    });

    db.Invoices.Add(new Invoice
    {
        Id = Guid.NewGuid(),
        OrderId = message.OrderId,
        TotalInCents = message.TotalInCents
    });

    try
    {
        // L'effet métier et la preuve de traitement sont atomiques.
        await db.SaveChangesAsync(cancellationToken);
    }
    catch (DbUpdateException exception)
        when (exception.InnerException is SqliteException sqlite &&
              sqlite.SqliteErrorCode == 19)
    {
        // Deux requêtes peuvent avoir lu "absent" simultanément.
        // La clé primaire de l'Inbox arbitre le conflit en base.
        db.ChangeTracker.Clear();

        if (await db.InboxMessages.AsNoTracking().AnyAsync(
            x => x.Id == message.MessageId, cancellationToken))
        {
            return Results.Ok(new { AlreadyProcessed = true });
        }

        // Une autre contrainte a échoué : ne pas masquer cette erreur.
        throw;
    }

    return Results.Ok(new { AlreadyProcessed = false });
});

app.MapGet("/invoices", async (
    BillingDbContext db, CancellationToken cancellationToken) =>
    await db.Invoices.AsNoTracking().ToListAsync(cancellationToken));

await app.RunAsync();

public sealed record OrderCreated(
    Guid MessageId,
    Guid OrderId,
    long TotalInCents,
    DateTime OccurredAtUtc);

public sealed class InboxMessage
{
    public Guid Id { get; set; }
    public DateTime ProcessedAtUtc { get; set; }
}

public sealed class Invoice
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public long TotalInCents { get; set; }
}

public sealed class BillingDbContext(
    DbContextOptions<BillingDbContext> options) : DbContext(options)
{
    public DbSet<InboxMessage> InboxMessages => Set<InboxMessage>();
    public DbSet<Invoice> Invoices => Set<Invoice>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<InboxMessage>().HasKey(x => x.Id);
        modelBuilder.Entity<Invoice>().HasKey(x => x.Id);
    }
}

Le contrôle préalable améliore le traitement des doublons courants. La clé primaire de l’Inbox protège aussi contre les réceptions concurrentes. Si SQLite signale temporairement un verrouillage, la requête échoue et le worker la retente ; le message n’est pas marqué comme envoyé.

Les deux projets déclarent volontairement le même contrat pour rendre l’exemple autonome. Dans un projet réel, ce contrat doit être versionné et géré explicitement entre producteurs et consommateurs.

4. Exécuter les applications

Depuis le dossier OutboxDemo, ouvrir deux terminaux.

Dans le premier :

dotnet run --project BillingService --no-launch-profile --urls http://localhost:5002

Dans le second :

dotnet run --project OrdersService --no-launch-profile --urls http://localhost:5001

Les bases sont créées automatiquement dans le répertoire de travail de chaque processus. Garder le même répertoire et les mêmes fichiers SQLite lors des redémarrages. Si le modèle est modifié pendant les essais, EnsureCreatedAsync ne met pas à jour un schéma existant.

5. Créer une commande

Depuis PowerShell :

$result = Invoke-RestMethod -Method Post `
    -Uri 'http://localhost:5001/orders' `
    -ContentType 'application/json' `
    -Body '{"totalInCents":12500}'

$result
Invoke-RestMethod 'http://localhost:5001/outbox'
Invoke-RestMethod 'http://localhost:5002/invoices'

Une commande de 125 € est enregistrée. Après le passage du worker, une facture doit apparaître et SentAtUtc doit être renseigné dans l’Outbox. La transmission peut prendre quelques secondes.

6. Observer la reprise après une indisponibilité

  1. Arrêter BillingService.
  2. Créer une nouvelle commande.
  3. Consulter /outbox : son message reste en attente, avec un compteur de tentatives et une erreur.
  4. Redémarrer BillingService sans supprimer sa base.
  5. Attendre une nouvelle tentative, au maximum environ une minute avec cette politique.
  6. Vérifier la présence de la facture et le marquage du message comme envoyé.

Pour observer la durabilité locale, arrêter aussi OrdersService alors qu’un message attend, puis le redémarrer avec la même base. Le worker doit reprendre ce message.

7. Vérifier le traitement d’un doublon

À partir d’une commande déjà transmise, renvoyer exactement son message :

$messages = Invoke-RestMethod 'http://localhost:5001/outbox'
$message = $messages | Where-Object {
    $_.id -eq $result.messageId
} | Select-Object -First 1

Invoke-RestMethod -Method Post `
    -Uri 'http://localhost:5002/events/order-created' `
    -ContentType 'application/json' `
    -Body $message.payload

Invoke-RestMethod 'http://localhost:5002/invoices'

La réponse doit indiquer AlreadyProcessed = true. La répétition du même message ne doit pas créer une nouvelle facture.

Cette déduplication porte sur l’identifiant du message. Si le client répète POST /orders, l’exemple crée une autre commande et un autre message : l’idempotence de l’API de création est un problème distinct, généralement traité par une clé d’idempotence de requête.

Pourquoi les doublons sont-ils inévitables ?

Le destinataire peut valider sa transaction, puis la connexion peut se couper avant que l’émetteur reçoive la réponse. Il peut aussi répondre correctement alors que l’émetteur s’arrête avant d’enregistrer SentAtUtc.

L’émetteur ne peut pas distinguer avec certitude une transmission perdue d’une transmission réussie dont la confirmation a été perdue. Il retente donc l’envoi avec le même identifiant, et le destinataire reconnaît le message déjà traité.

Avec un broker, il faut attendre sa confirmation de publication avant de marquer le message Outbox. Cette confirmation ne signifie pas que le consommateur a terminé son traitement : les confirmations de publication et les acquittements de consommation remplissent des rôles distincts, comme l’explique la documentation RabbitMQ.

Préparer une mise en production

Coordonner plusieurs workers

La requête de lecture de cet exemple ne réserve pas les messages. Si plusieurs instances partagent la même base, elles peuvent lire et envoyer les mêmes lignes.

Une mise en œuvre distribuée doit prévoir une réservation atomique des messages, par exemple un bail avec un propriétaire et une date d’expiration, ou des verrous adaptés au moteur de base de données. La réservation doit expirer pour permettre la reprise après un arrêt. L’idempotence reste nécessaire, même avec cette coordination.

Définir l’ordre utile

Trier par date donne un ordre de lecture, mais ne garantit pas l’ordre de livraison : un message en échec peut être dépassé par un autre. Lorsque plusieurs événements d’une même commande doivent rester ordonnés, prévoir une séquence par commande et une stratégie qui respecte cet ordre, éventuellement avec des sessions ou partitions du broker.

Gérer les erreurs durables

Les nouvelles tentatives doivent comporter un délai croissant et, en production, une variation aléatoire pour éviter des reprises simultanées. Un contrat invalide ou incompatible ne devient pas valide en le renvoyant indéfiniment : prévoir un état d’échec définitif, une mise à l’écart, une alerte et un mécanisme de correction ou de rejeu.

Le worker de démonstration retente toutes les erreurs ; cette politique sert à rendre les reprises visibles.

Superviser et conserver les données

Surveiller le nombre de messages en attente, l’âge du plus ancien, les tentatives et les messages bloqués. Définir une politique de nettoyage des lignes envoyées, ainsi qu’une durée de conservation des identifiants Inbox couvrant la période possible de rejeu. Supprimer trop tôt ces identifiants permettrait de réappliquer un ancien message.

Encadrer les effets externes

L’Inbox protège ici la création d’une facture dans la même base. Elle ne rend pas atomique un paiement externe, l’envoi d’un courriel ou un appel HTTP réalisé pendant le traitement. Pour ces effets, utiliser une clé d’idempotence reconnue par le système externe ou une nouvelle Outbox du côté consommateur.

Sécuriser et versionner

Prévoir authentification, autorisation, chiffrement des échanges, validation des contrats et protection des données contenues dans les messages et journaux. Les endpoints d’observation de cette démonstration doivent être protégés ou retirés.

Quand utiliser ce pattern ?

L’Outbox est adaptée lorsqu’une modification métier doit conduire de manière fiable à une notification externe : commande créée, facture émise, compte ouvert ou stock modifié. Elle est particulièrement utile lorsque le service destinataire ou le broker peut être temporairement indisponible.

Elle introduit une table supplémentaire, un processus de publication, des délais de propagation et des contraintes d’exploitation. Si toute l’opération se déroule dans une même base et une même transaction locale, ces mécanismes ne sont pas forcément nécessaires.