Die erste Version einer Integration entsteht für den Happy Path, weil der Happy Path in der Anbieter-Dokumentation steht und aus der Sandbox zurückkommt. Die zweite Version entsteht nach einem Incident, unter Zeitdruck, von der Person, die Bereitschaft hatte.
Der Unterschied zwischen beiden Versionen ist keine Raffinesse. Es sind eine Handvoll Annahmen: dass der Aufruf genau einmal ankommt, dass die Antwort zurückkommt, dass das andere System danach dasselbe denkt wie man selbst. Nichts davon stimmt — und von Anfang an dafür zu entwerfen kostet überraschend wenig.
Von der Annahme ausgehen, dass es genau jetzt ausgefallen ist
Für jedes externe System, von dem man abhängt, entscheiden vier Fragen den Entwurf — und es lohnt sich, sie schriftlich zu beantworten, bevor der erste Request rausgeht:
- Was passiert mit einer Nutzeraktion, wenn dieses System nicht verfügbar ist? Ablehnen, einreihen oder degradieren — pro Operation bewusst wählen. "Fehler anzeigen" ist eine gültige Antwort für eine Adressprüfung und eine unzumutbare für eine Zahlung, die schon erfolgreich war.
- Was ist der schlimmste Fall, wenn ein Aufruf zweimal ausgeführt wird? Eine doppelte Belastung, eine doppelte Lieferung und eine doppelte E-Mail sind drei sehr unterschiedliche Schweregrade und verdienen drei unterschiedliche Schutzniveaus.
- Wie erfahren wir, dass wir uneinig sind? Wenn niemand die beiden Systeme regelmäßig vergleicht, lautet die Antwort: von einem Kunden.
- Wem gehört der Retry? Uns, dem Anbieter, einer Queue oder einem Menschen. Zwei Parteien, die dieselbe Operation wiederholen, sind ein zuverlässiger Weg, sie zu duplizieren.
Das sind fünfzehn Minuten pro Integration — und sie bestimmen fast alles, was folgt.
Idempotenz ist ein Key, keine Hoffnung
Ein Timeout sagt nichts darüber, ob die Operation stattgefunden hat. Der Request kann vor dem Ankommen verloren gegangen sein — oder ausgeführt worden und auf dem Rückweg verloren. Von außen ist das identisch. Deshalb ist "hat es funktioniert?" unbeantwortbar und die falsche Frage, auf der man baut.
Die Antwort ist, das Wiederholen harmlos zu machen. Einen Key erzeugen, der sich aus der Absicht ableitet — nicht aus dem Versuch — und ihn mit jedem Retry mitsenden:
// Aus dem abgeleitet, was wir erreichen wollen: jeder Retry
// derselben Absicht erzeugt denselben Key.
const idempotencyKey = createHash('sha256')
.update([orderId, 'capture', attemptGroupId].join(':'))
.digest('hex');
await payments.capture(
{ orderId, amount },
{ headers: { 'Idempotency-Key': idempotencyKey } },
);Zwei Fehler gilt es zu vermeiden. Den Key nicht pro Versuch erzeugen — eine neue UUID bei jedem Retry bietet überhaupt keinen Schutz, und dieser Bug besteht jeden Test, der keinen Timeout simuliert. Und aus demselben Grund keinen Zeitstempel in die Ableitung nehmen.
Auf der Empfangsseite gilt dieselbe Regel für eigene Webhook-Endpoints. Die Event-ID des Anbieters mit einem Unique-Constraint speichern und die Datenbank das Duplikat ablehnen lassen:
create table webhook_events (
provider text not null,
event_id text not null,
received_at timestamptz not null default now(),
payload jsonb not null,
processed_at timestamptz,
primary key (provider, event_id)
);Erst einfügen, dann verarbeiten. Eine Unique-Violation beim Insert ist kein Fehler — das ist das System bei der Arbeit. Anbieter liefern erneut aus, manchmal in falscher Reihenfolge, manchmal Wochen später, und immer mindestens einmal mehr als erwartet.
Niemals in einem Zug in zwei Systeme schreiben
Das ist die häufigste Ursache für stille Datendivergenz:
// Kaputt auf eine Weise, die erst unter Last auffaellt.
await db.orders.update({ id, status: 'paid' });
await erp.createInvoice(order); // <- Crash hier und beide sind fuer immer uneinigEs gibt keine Anordnung dieser zwei Zeilen, die sicher ist, weil es keine Transaktion gibt, die beide umspannt. Die Reihenfolge zu tauschen ändert nur, welche Seite falsch liegt.
Das Outbox-Muster löst das mit einem Insert. Die Absicht in die eigene Datenbank schreiben, in derselben Transaktion wie die Zustandsänderung, und ein separater Worker liefert sie aus:
await db.transaction(async (tx) => {
await tx.orders.update({ id, status: 'paid' });
await tx.outbox.insert({
topic: 'erp.invoice.create',
payload: { orderId: id },
availableAt: new Date(),
});
});Damit ist der Schreibvorgang bezogen auf die eigene Datenbank atomar, und die Auslieferung wird ein unabhängiges, wiederholbares Anliegen. Die Garantie ist At-least-once — und genau deshalb braucht die Empfangsseite die Idempotenz-Arbeit von oben. Die beiden Muster sind keine Alternativen, sie sind zwei Hälften eines Entwurfs.
Der Worker selbst bleibt klein: einen Batch mit select ... for update skip locked übernehmen, ausliefern, als erledigt markieren oder neu einplanen. Ein paar Dutzend Zeilen Code, die eine ganze Kategorie von Incidents entfernen.
Retries, die es nicht schlimmer machen
Alles zu wiederholen ist fast so schlecht wie nichts zu wiederholen, denn ein Retry-Sturm gegen eine kämpfende Abhängigkeit ist der Weg, auf dem aus einer langsamen Integration ein Ausfall wird. Zuerst den Fehler klassifizieren:
- Wiederholbar: Timeouts, Verbindungsabbrüche,
429,502,503. Exponentielles Backoff mit Jitter — ohne Jitter wiederholen alle hängenden Jobs im selben Moment, und die Abhängigkeit bekommt bei jedem Comeback eine Welle ab. - Endgültig:
400,422, eine abgelehnte Karte, ein Validierungsfehler. Wiederholen hilft nie. Einmal laut scheitern und aufhören. - Unklar: alles, wo der Request angewendet worden sein könnte — ein
409oder ein Timeout bei einem Schreibvorgang. Durch Nachfragen des aktuellen Zustands abgleichen statt raten. Nie blind wiederholen.
Jeder wiederholbare Job braucht eine begrenzte Versuchszahl und ein Dead-Letter-Ziel. Ein unbegrenzter Retry ohne Sichtbarkeit ist keine Resilienz, sondern ein Job, der in sechs Monaten noch scheitert, ohne dass es jemand weiß. Die Dead-Letter-Queue ist kein Fehlerzustand — sie ist die Queue, in die ein Mensch schauen soll, und sie muss klein genug dafür sein.
Abgleich ist ein Feature
Selbst mit allem Genannten driften zwei Systeme. Jemand bearbeitet einen Datensatz direkt im ERP, ein Webhook ging während einer Anbieterstörung verloren, ein Deploy lief mitten in einem Batch.
Also den Vergleich einplanen. Ein nächtlicher Job, der die Bestellungen von gestern durchläuft, die eigene Sicht gegen das andere System hält und Abweichungen in einen Kanal meldet, den ein Mensch liest. Ein Nachmittag Arbeit. Jedes Projekt, in dem ich das erlebt habe, fand in der ersten Woche echte Probleme — und das ist der Punkt: Diese Probleme existierten schon, die Frage war nur, ob man sie selbst oder der Kunde zuerst findet.
Findet der Job eine Abweichung, muss das Log die Abfolge erklären können. Genau hier zahlt sich eine append-only Event-Historie aus; dasselbe Argument findet sich in Ein Marktplatz-MVP bauen, der seinen eigenen Erfolg übersteht.
Vier Zahlen pro Integration
Dashboards sammeln sich an, bis niemand mehr hinsieht. Pro Integration decken vier Zahlen fast jede echte Frage ab:
- Erfolgsrate über ein gleitendes Fenster, nicht seit Anbeginn.
- p95-Latenz, weil der Durchschnitt die Timeouts verdeckt, die die unklaren Fälle erzeugen.
- Outbox-Tiefe und Alter des ältesten offenen Eintrags. Die Tiefe allein sieht gut aus, während eine vergiftete Nachricht einen Tag festhängt; das Alter verrät es.
- Dead-Letter-Anzahl seit dem letzten Blick. Ist die nie null, ist sie Dekoration.
Auf das Alter des ältesten offenen Eintrags alarmieren, nicht auf einzelne Fehler. Einzelne Fehler sind normal und trainieren das Team, den Kanal zu ignorieren. Eine Nachricht, die seit einer Stunde feststeckt, ist nicht normal — und das früheste Signal, dass ein Mensch gebraucht wird.
Nichts davon ist fortgeschrittene Arbeit an verteilten Systemen. Es ist eine Handvoll Muster, die davon ausgehen, dass das Netz unzuverlässig und die andere Seite unvollkommen ist — was über ein ausreichend langes Fenster einfach eine korrekte Beschreibung von Produktion ist.



