Letztes Jahr, Mandat bei einem Onlinehändler mit 60 Mitarbeitenden. Die Geschäftsführerin ruft mich an, weil ein Kunde zweimal für dieselbe Bestellung belastet wurde. Support-Ticket, Rückbuchung, peinliche Entschuldigung. Nicht dramatisch — aber als ich mir den Code ansehe, wird mir klar: Das ist kein Einzelfall. Das ist ein strukturelles Problem, das nur bisher nicht auffiel, weil der Zahlungsanbieter meistens schnell genug antwortete.
Der Entwickler — ein guter, erfahrener Mann — hatte ChargeAsync aufgerufen, dann SaveChangesAsync. Dazwischen ein Timeout. Das Geld weg, die Bestellung im Draft-Status, der Kunde frustriert. Kein Logging auf dem Fehlerfall. Keine Idempotency Key. Kein Kompensationsmechanismus. Nur ein catch mit log.Error("Payment failed") und einem stillen Rückgabewert.
Was Milan Jovanović sagt — und er hat Recht
Milan Jovanović hat einen sehr soliden Artikel darüber geschrieben, was passiert, wenn ein Use Case "halb durchläuft". Seine Kernthese: Ein Use Case sieht aus wie eine Transaktion, weil er hinter einem einzigen Methodenaufruf steckt — ist aber keiner, sobald er mehr als ein System berührt.
Er kategorisiert Side Effects in drei Klassen: transaktional (gehört zur DB-Transaktion), extern und reversibel (z. B. eine Zahlung, die man erstatten kann), extern und irreversibel (E-Mails, SMS — raus ist raus). Dann beschreibt er drei Strategien: transaktionale Arbeit ans Ende ziehen, irreversible Side Effects über den Outbox-Pattern aus dem Use Case herauslösen, und externe Calls idempotent oder kompensierbar machen — konkret über Idempotency Keys bei Stripe, Adyen oder Braintree.
Die Codebeispiele sind sauber. Die Argumentation ist wasserdicht. Wer in einer Umgebung mit dedizierten Teams, CI/CD-Pipeline und einem Platform-Engineer arbeitet, kann das direkt umsetzen.
Wie das im KMU wirklich landet
Das Problem ist nicht, dass der Artikel falsch liegt. Das Problem ist, was er stillschweigend voraussetzt.
Der Outbox-Pattern kostet dich einen Worker-Prozess, Monitoring dafür, Retry-Logik, Idempotency auf Empfängerseite — und jemanden, der das alles wartet. In einem Unternehmen mit einem bis drei Entwicklern, die nebenbei auch noch die Azure-Subscription, das WordPress der Marketingabteilung und den EDI-Anschluss zum Großhändler betreuen, ist das kein Pattern, das du mal eben einführst. Das ist ein Infrastrukturprojekt.
Ich habe in den letzten drei Jahren bei vielleicht einem Dutzend KMUs geschaut, wie Zahlungsintegrationen aussehen. Meistens ist es so: Stripe oder Klarna via SDK, ein Use Case-Handler, kein Outbox, kein Idempotency Key, kein explizites Fehlerhandling auf dem externen Call. Und trotzdem läuft es — weil der Happy Path 99,8 % der Fälle abdeckt und die restlichen 0,2 % im Support-Ticket landen.
Das klingt fahrlässig, ist es aber oft nicht. Die Frage ist immer: Was kostet das Problem, und was kostet die Lösung? Bei 200 Transaktionen pro Tag und einem Entwickler, der 60 % seiner Zeit in Featurearbeit steckt, ist ein Outbox-Pattern mit eigenem Worker vielleicht nicht der richtige erste Schritt.
Was sich trotzdem sofort lohnt — ohne Infrastruktur-Overhead
Milans Strategie 1 und 3A kosten fast nichts und bringen sofort was.
Strategie 1 — transaktionale Arbeit ans Ende ziehen ist eine Frage von fünf Minuten Refactoring. Zahlung zuerst aufrufen, erst danach committen. Wenn die Zahlung scheitert, gibt es nichts zu rollen. Wenn sie durchläuft und SaveChangesAsync schmeißt — ja, das ist immer noch ein Problem, aber du hast zumindest die häufigste Fehlerkategorie eliminiert. Das sollte in jedem KMU-Projekt Standard sein. Keine Diskussion.
Strategie 3A — Idempotency Keys ist bei Stripe, Adyen und Braintree drei Zeilen Code. Du übergibst die Order-ID als Key, und ein Retry ist auf Anbieterseite eine No-Op. Das schützt dich vor dem Doppelbelastungs-Problem ohne jede zusätzliche Infrastruktur. Ich verstehe bis heute nicht, warum das nicht in jedem Tutorial als erstes steht. Es steht meistens gar nicht drin.
Diese zwei Maßnahmen zusammen lösen den konkreten Fall, den Milan am Anfang beschreibt — den Duplicate-Charge-Fehler — vollständig. Ohne Worker. Ohne Event-Bus. Ohne einen zweiten Service.
Was du erst brauchst, wenn du skalierst: Den vollständigen Outbox-Pattern mit Background-Worker lohnt sich anzugehen, wenn du täglich Tausende von Transaktionen hast, mehrere Teams an derselben Codebase arbeiten, oder du anfängst, Events an externe Systeme zu publishen (Warenwirtschaft, ERP, Versanddienstleister). Vorher ist es — ehrlich gesagt — Overkill, der dich in Wartungsaufwand zieht, den dein Team nicht stemmen kann.
Die Sache, die im Artikel fehlt
Es gibt eine vierte Kategorie von Side Effect, die ich in fast jedem Mittelstandsprojekt sehe und die bei Milan nicht vorkommt: der interne synchrone Aufruf in ein System, das dir nicht gehört. Also: dein ERP über eine SOAP-Schnittstelle aus 2009, dein Warenwirtschaftssystem mit einer REST-API ohne Dokumentation, das CRM des Mutterkonzerns mit einer Latenz von 8 Sekunden im Worst Case.
Das ist weder "transaktional" noch "external and reversible" im Sinne von Stripe. Das ist ein System, das möglicherweise idempotent ist, möglicherweise nicht, und bei dem du keine Idempotency Keys übergeben kannst, weil die API das nicht vorsieht. Hier hilft nur: lokale Zustandsmaschine, explizites Logging jedes Calls mit Timestamp und Response-Körper, und eine menschenlesbare Alarm-Schwelle, bei der dein Support-Team sofort sieht, was schief gelaufen ist.
Das ist kein elegantes Pattern. Aber es ist das, was im Mittelstand funktioniert.
Der vollständige Original-Beitrag von Milan Jovanović: When Your Use Case Half-Succeeds: Designing for Partial Failure in .NET
Wenn du dir nicht sicher bist, welche dieser Strategien für dein konkretes System gerade die richtige ist — oder ob deine Zahlungsintegration gerade still auf einen Doppelbelastungs-Bug wartet — dann reden wir darüber.
— Bernhard