Page chargée : Article

Blog

Wolverine en .NET : ce que c’est, et pourquoi je l’utilise

Wolverine expliqué par la pratique : handlers sans interface, cascading messages, outbox PostgreSQL avec EF Core, domain events et retries. Ma config et mes raisons.

Si tu fais du CQRS en .NET, tu as sûrement croisé MediatR. C’est le réflexe de beaucoup d’équipes : une command, un handler, Send, et c’est parti.

Sur mes projets récents, dont Cobria, l’ERP du bâtiment sur lequel je bosse, j’utilise Wolverine à la place. Pas pour le plaisir de changer d’outil, mais parce qu’il règle d’un coup trois problèmes que MediatR ne couvre pas : le messaging asynchrone, l’outbox transactionnelle et le retry propre des messages qui échouent.

Je t’explique ce que c’est, comment je m’en sers, et ce qu’il faut savoir avant de te lancer.

Wolverine en deux mots

Wolverine est un framework open source créé par Jeremy D. Miller (l’équipe JasperFx, déjà derrière Marten). C’est à la fois :

  • un mediator in-process, comme MediatR : tu envoies une command, un handler la traite ;
  • un message bus : queues locales ou transports externes (RabbitMQ, Azure Service Bus, Kafka…), avec retries et scheduling ;
  • un moteur de messaging durable : inbox et outbox persistées en base, pour ne jamais perdre un message.

Le gros intérêt, c’est d’avoir tout ça dans un seul outil, avec un seul modèle mental. Le jour où une command synchrone doit devenir un traitement asynchrone, tu changes une ligne de config, pas ton architecture.

Un handler, c’est juste une méthode

Premier truc qui surprend : pas d’interface IRequestHandler<T>, pas de classe de base imposée. Wolverine trouve tes handlers par convention : une classe qui finit par Handler, une méthode qui s’appelle Handle.

public record CreateQuoteDraft(Guid CustomerId, IReadOnlyList<QuoteLineInput> Lines);

public static class CreateQuoteDraftHandler
{
    public static Guid Handle(CreateQuoteDraft command, AppDbContext db)
    {
        var quote = Quote.CreateDraft(command.CustomerId, command.Lines);
        db.Quotes.Add(quote);
        return quote.Id;
    }
}

Les dépendances arrivent directement en paramètres de la méthode (ici le DbContext), résolues depuis le conteneur DI. Et la valeur de retour devient la réponse de l’appel :

var quoteId = await bus.InvokeAsync<Guid>(new CreateQuoteDraft(customerId, lines), cancellationToken);

Tu remarques qu’il n’y a pas de SaveChangesAsync dans le handler ? C’est voulu, on y revient plus bas avec la config.

Sous le capot, Wolverine ne fait pas de réflexion à chaque appel : au démarrage, il génère et compile le code qui appelle tes handlers. Résultat, c’est rapide, et tu peux même lire ce code généré avec dotnet run -- codegen preview quand tu veux comprendre ce qui se passe.

Les cascading messages

Deuxième truc que j’adore : un handler peut retourner un message, et Wolverine le publie pour toi.

public record SendQuote(Guid QuoteId);
public record QuoteSent(Guid QuoteId);

public static class SendQuoteHandler
{
    public static async Task<QuoteSent> Handle(SendQuote command, AppDbContext db, CancellationToken ct)
    {
        var quote = await db.Quotes.FindAsync([command.QuoteId], ct);
        quote!.MarkAsSent();
        // Pas de bus à injecter: le message retourné est publié par Wolverine
        return new QuoteSent(quote.Id);
    }
}

Ton handler reste une fonction presque pure : il reçoit une command, il renvoie ce qui doit se passer ensuite. Il se teste sans mock de bus, en vérifiant simplement ce qu’il retourne.

L’outbox, ou comment ne plus jamais perdre un message

C’est la vraie raison pour laquelle j’utilise Wolverine.

Le problème est connu sous le nom de dual write. Tu enregistres un devis en base, puis tu publies un message « devis envoyé » pour déclencher l’email au client. Si l’appli crashe entre les deux, tu te retrouves avec un devis marqué envoyé et aucun email. Dans l’autre sens, c’est pire : un email parti pour un devis qui n’a jamais été enregistré.

L’outbox règle ça : le message est écrit dans la même transaction que tes données, dans une table de ta base. Une fois la transaction validée, Wolverine le livre. Si l’appli redémarre entre-temps, le message est toujours là et il part au redémarrage.

Voici l’essentiel de ma config, avec PostgreSQL et EF Core :

builder.Host.UseWolverine(opts =>
{
    // Inbox et outbox persistées dans PostgreSQL, dans un schéma dédié
    opts.PersistMessagesWithPostgresql(connectionString, "wolverine");

    // Les messages partent dans la même transaction que le DbContext
    opts.UseEntityFrameworkCoreTransactions();

    // Chaque handler qui utilise un DbContext est enveloppé dans une transaction
    opts.Policies.AutoApplyTransactions();

    // Même les queues locales passent par l'inbox: rien ne se perd au redémarrage
    opts.Policies.UseDurableLocalQueues();
});

Quatre lignes, et tu as une messagerie fiable sans monter de broker. C’est ce qui explique l’absence de SaveChangesAsync dans mes handlers : la transaction et la sauvegarde sont gérées autour d’eux.

Les domain events, branchés une fois pour toutes

En DDD, ce sont tes agrégats qui lèvent des events : Quote.MarkAsSent() ajoute un QuoteSentDomainEvent à sa liste interne. Encore faut-il les dispatcher au bon moment.

J’ai fait ça une fois pour toutes avec un middleware Wolverine. Sa méthode After s’exécute après chaque handler, récupère les events des agrégats suivis par EF Core, et les envoie au bon endroit :

public class DomainEventDispatcherMiddleware
{
    public async Task After(AppDbContext db, IMessageBus bus, IDbContextOutbox outbox, IEventMapper mapper)
    {
        outbox.Enroll(db);

        // Un handler d'event peut lever de nouveaux events: on boucle jusqu'à ce qu'il n'y en ait plus
        while (true)
        {
            var events = db.ChangeTracker.Entries<IAggregateRoot>()
                .SelectMany(entry => entry.Entity.ClearDomainEvents())
                .ToList();
            if (events.Count == 0) break;

            foreach (var domainEvent in events)
            {
                // Ce qui sort du module passe par l'outbox, le reste est traité tout de suite
                if (mapper.MapToIntegrationEvent(domainEvent) is { } integrationEvent)
                    await outbox.PublishAsync(integrationEvent);
                else
                    await bus.InvokeAsync(domainEvent);
            }
        }

        await outbox.SaveChangesAndFlushMessagesAsync();
    }
}

Il s’enregistre en une ligne avec opts.Policies.AddMiddleware<DomainEventDispatcherMiddleware>().

La distinction compte. Un domain event reste dans le module et se traite tout de suite, dans la même transaction. Un integration event sort du module (vers un autre module, ou un autre service) et passe par l’outbox, pour être livré de façon fiable. Dans un modular monolith, c’est ce qui garde tes modules découplés sans sacrifier la cohérence.

L’ordre avant la vitesse

Par défaut, une queue Wolverine traite plusieurs messages en parallèle. Pour mes events, j’ai fait le choix inverse :

opts.LocalQueueFor<IEvent>()
    .Sequential()
    .MaximumParallelMessages(1)
    .UseDurableInbox();

Les events sont traités un par un, dans l’ordre. Je perds un peu de débit, mais je gagne en sérénité : dans un ERP, traiter « facture payée » avant « facture émise » coûte bien plus cher que quelques millisecondes. Tant que tu n’as pas de vrai problème de volume, je te conseille de commencer par là.

Retries et scheduling

Un message qui échoue n’est pas perdu : Wolverine sait le rejouer, et tu choisis la stratégie selon le type d’erreur.

// Erreur réseau passagère: on réessaie avec un délai croissant
opts.Policies.OnException<TimeoutException>()
    .RetryWithCooldown(TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(1));

// Pour le reste, une seule nouvelle chance avant de laisser tomber
opts.Policies.OnException<Exception>().RetryTimes(1);

Et pour tout ce qui doit se passer plus tard, il y a le scheduling, persisté lui aussi :

// Relance automatique si la facture n'est pas payée dans sept jours
await bus.ScheduleAsync(new SendPaymentReminder(invoiceId), TimeSpan.FromDays(7));

Pas de Hangfire à ajouter, pas de cron à maintenir. Le message attend en base et part au bon moment, même si l’appli a redémarré entre-temps. Comme je déploie sur une seule instance, j’ai réglé opts.Durability.Mode = DurabilityMode.Solo, ce qui simplifie la gestion de ces jobs.

Garder Wolverine à sa place

Dernier conseil d’architecte : je n’injecte pas IMessageBus partout. Mes modules dépendent d’une petite interface maison, et une seule classe fait le lien avec Wolverine.

internal sealed class MessageService(IMessageBus bus) : IMessage
{
    public Task<TResult> InvokeAsync<TResult>(object message, CancellationToken ct = default)
        => bus.InvokeAsync<TResult>(message, ct);
}

Si un jour je dois changer d’outil, je sais exactement où ça se passe. C’est trois lignes, et c’est une assurance qui ne coûte rien.

Ce qu’il faut savoir avant de te lancer

Wolverine n’est pas parfait, et il vaut mieux le savoir avant.

  • Les conventions déroutent au début. Pas d’interface, des paramètres injectés par magie : la première semaine, tu te demandes souvent pourquoi ton handler n’est pas trouvé. La commande codegen preview devient vite ta meilleure amie.
  • La doc est riche, mais dense. Il y a beaucoup de fonctionnalités, et pas toujours d’exemple pour ton cas précis.
  • L’outbox ajoute des tables à ta base. C’est le prix de la fiabilité, mais pense-y dans tes migrations et tes sauvegardes.

Et si tu viens de MediatR, sache que la migration se fait bien : une command reste une command, un handler reste un handler. Tu gagnes surtout tout ce qui vient autour. Le passage de MediatR sous licence commerciale en 2025 a d’ailleurs poussé pas mal d’équipes à regarder ailleurs.

Le mot de la fin

Wolverine m’a fait gagner sur trois plans : moins de code de plomberie, des messages qui ne se perdent plus, et une architecture qui peut évoluer du synchrone vers l’asynchrone sans tout réécrire. Pour un modular monolith en CQRS, c’est devenu mon choix par défaut.

Si tu hésites à franchir le pas, ou si tu veux un avis sur ton architecture de messaging, écris-moi. On en parle volontiers.