Intro

Event-driven architecture wint snel aan populariteit in .NET-teams.

Vroeg of laat komt dan deze gedachte:

“Hoe moeilijk kan het zijn? We spreken gewoon rechtstreeks met Azure Service Bus. We bouwen zelf wel wat we nodig hebben.”

Mijn hot take:

Zelf een event-driven “mini-framework” bouwen bovenop Azure Service Bus (of RabbitMQ, Kafka, …) is bijna altijd een verspilling van tijd en geld.
Niet omdat je het niet kan bouwen, maar omdat je de echte kost onderschat.

Dit artikel is voor senior engineers, team leads en architecten die voor de keuze staan: zelf bouwen, rechtstreeks met het transport werken, of een bestaand EDA-framework gebruiken. Dit is geen vergelijking tussen bestaande frameworks.


1. Waarom messaging en EDA bedriegelijk eenvoudig lijkt

In het begin ziet alles er onschuldig uit:

  • “We hebben een queue.”
  • “We sturen een bericht.”
  • “We hebben een handler.”

En op dag 1 werkt dat. Demo ok. Stakeholders tevreden.

Maar dan komt de echte wereld:

  • Een dependency is down.
  • Een externe API time-out.
  • Berichten komen dubbel binnen.
  • Een handler wordt trager onder load.
  • Er zijn drie services betrokken in één business flow.

En plots heb je dit soort vragen:

  • Hoe doen we retries zonder dat we de queue platleggen?
  • Wat als een bericht 3× faalt? 10×? 100×?
  • Hoe voorkomen we dubbele verwerking?
  • Hoe houden we state bij over langlopende processen?
  • Hoe troubleshoot je één business flow over 5 services heen?

Dat is exact de problem space waarvoor EDA-frameworks ontstaan zijn.
En toch denken we vaak: “Ach, we schrijven het zelf wel.”


2. Wat mature EDA-frameworks voor je oplossen (en jij onderschat)

Frameworks zoals NServiceBus, MassTransit, Wolverine, Brighter, … zijn niet “gewoon wat wrappers rond de SDK”.
Ze lossen hardnekkige, terugkerende problemen op waarmee teams zich anders jarenlang bezighouden.

Een (onvolledige) greep:

2.1 Transport abstracties

  • Azure Service Bus, RabbitMQ, SQL, SQS, Kafka, …
  • Zelfde mental model, ander transport.

Zonder framework: jij beheert alle provider-specifieke quirks, connection handling, timeouts, naming conventions.

2.2 Retries, backoff & failure policies

  • Immediate retries, delayed retries, exponential backoff.
  • Circuit-breaking, “move to error queue”.

Zonder framework: jij schrijft de retry-logica, beheert lock times, dead-lettering, policies per type, enz.

2.3 Dead-letter handling & error queues

  • Gecontroleerd gedrag wanneer dingen blijven falen.
  • Tools/UI om problematische berichten te inspecteren en te replayen.

Zonder framework: iemand schrijft een “DLQ watcher” script, iemand anders een “repair job”, niemand heeft het volledig overzicht. Ik heb al scenarios gezien waar teams hun architectuur aanpassen enkel en alleen om deze tooling zelf te schrijven.

2.4 Idempotency & duplicate detection

  • Idempotent handlers, outbox pattern, message de-duplication.

Zonder framework: “if (AlreadyProcessed)” checks, custom locks, of gewoon hopen dat het niet zo erg is.

2.5 Sagas / process managers

  • Long-running workflows, state tussen stappen, compensating actions.
  • Denk: order → payment → shipping → notification.

Zonder framework: je eindigt met een paar gigantische services, vol handgemaakte state-machines en fragile custom logic.

2.6 Observability & monitoring

  • Correlation IDs, tracing per message, metrics, dashboards.

2.7 Versioning & contract governance

  • Backward compatibility, message evolutie, tooling rond schema changes.

3. Voorbeeld uit het leven gegrepen: ik implementeer “even” zelf exponential retries

Laten we concreet worden.

Scenario uit de praktijk:

  1. We communiceren rechtstreeks met Azure Service Bus (geen framework).
  2. We willen exponential retries toevoegen.
  3. We denken: “We wikkelen de handler gewoon in een retry-loop. Simpel.”

Maar dan begint het.

3.1 Naïeve versie: alles in-memory

public async Task HandleAsync(ServiceBusReceivedMessage message)
{
    var retries = 0;
    var maxRetries = 5;

    while (true)
    {
        try
        {
            await ProcessMessageAsync(message);
            return;
        }
        catch (Exception ex)
        {
            retries++;

            if (retries > maxRetries)
                throw;

            var delay = TimeSpan.FromSeconds(Math.Pow(2, retries));
            await Task.Delay(delay);
        }
    }
}

Dit ziet er oké uit… tot je verder nadenkt:

  • Dit blokkeert je message lock terwijl je wacht.
  • Je verbruikt concurrency voor een message die gewoon zit te slapen.
  • Bij lange delays komt je lock te vervallen → message wordt opnieuw opgepikt → dubbele verwerking.
  • Je kan lock renewal aanzetten, maar dan:
    • Ga je locks zitten verlengen voor berichten die misschien toch zullen falen.
    • Moet je goed nadenken over max lock duration en timeouts downstream.

3.2 Tweede iteratie: we reschedulen de message zelf

Oké, nieuw plan: bij falen schedulen we het bericht opnieuw in de toekomst in, en markeren we het huidige als “afgehandeld” (of laten we het falen zodat het in DLQ gaat).

De code ziet er dan ongeveer zo uit:

public async Task HandleAsync(ServiceBusReceivedMessage message, ServiceBusSender sender)
{
    var attempt = GetAttemptNumber(message);
    try
    {
        await ProcessMessageAsync(message);
    }
    catch (Exception)
    {
        var nextAttempt = attempt + 1;

        if (nextAttempt > MaxAttempts)
        {
            await MoveToErrorQueueAsync(message);
            return;
        }

        var delay = TimeSpan.FromSeconds(Math.Pow(2, nextAttempt));

        var cloned = message.Clone(); // Pseudo-code
        cloned.ApplicationProperties["Attempt"] = nextAttempt;

        var scheduleTime = DateTimeOffset.UtcNow.Add(delay);
        await sender.ScheduleMessageAsync(cloned, scheduleTime);

        await CompleteMessageAsync(message);
    }
}

Dit werkt beter, maar nu:

  • Je moet metadata meedragen (attempt/retry count).
  • Je moet beslissen: complete je, abandon je, dead-letter je?
  • Je moet een error queue beheren.
  • Je moet rekening houden met scheduled message limits.
  • Je introduceert veel infrastructuurcode die je structureel moet onderhouden.

En dan hebben we het nog niet eens gehad over:

  • Policies per message type
  • Distributed tracing
  • Metrics rond retry gedrag
  • Testbaarheid en chaos scenarios

Voor je het weet, heb je:

  • Een retry engine
  • Een messaging utilities-project
  • 500+ regels “infra code”
  • En alsnog minder functionaliteit dan een bestaand framework

4. Hoe een framework dit oplost

Het precieze API-oppervlak verschilt per framework, maar conceptueel komt het neer op:

endpointConfiguration
    .Recoverability()
        .Immediate(cfg => cfg.NumberOfRetries(3))
        .Delayed(cfg =>
        {
            cfg.NumberOfRetries(5);
            cfg.TimeIncrease(TimeSpan.FromSeconds(10));
            cfg.UseExponentialBackoff();
        });
  • Je configureert, je implementeert niet.
  • De configuratie is mooi gescheiden waardoor de handler enkel business logica bevat.
  • Edge-cases zijn al jaren getest door andere teams.
  • Je krijgt retry tooling, monitoring, diagnostics, dashboarding, error queues, replay … out of the box.
  • En àlles wat hierboven staat, moet jij anders zelf onderhouden — vaak voor jaren.

5. “Maar frameworks zijn duur / complex”

De klassieke tegenargumenten:

5.1 “Framework X is duur.”

Klopt. Maar:

  • Hoeveel kost het om te prutsen/te onderhouden aan je eigen messaging stack?

5.2 “We hebben al die features niet nodig.”

Nog niet. Maar als je applicatie langer dan 2 jaar leeft, krijg je ze vroeg of laat wél nodig.

5.3 “We willen begrijpen wat er onder de motorkap gebeurt.”

Goed! Maar begrip vereist geen eigen implementatie.
Je kan de SDK perfect begrijpen én een framework gebruiken.

5.4 “Lock-in!”

Je bent altijd ergens locked-in.
De vraag is: ben je locked-in op battle-tested frameworks die hun nut allang hebben bewezen of op je eigen ongedocumenteerde spaghetti code? Daarboven zorgen deze frameworks er net voor dat je niet locked bent op de onderliggende infrastructuur. Om over te stappen van azure servicebus naar rabbitMQ vereist amper code-changes.


6. Conclusie

Bouw business value, geen infrastructuur die al bestaat. Messaging is moeilijker dan het lijkt. Frameworks bestaan niet zomaar.


AI-ondersteuning gebruikt voor taal en structuur; inhoud is volledig auteurseigen.