DDD in der Praxis: Wie das Fixieren des Wechselkurses bei Zahlungen vier kritische Bugs behebt
In Zahlungssystemen führen Fehler bei der Wechselkursberechnung zu finanziellen Abweichungen und Vertrauensverlust. Eine reale Go-Fallstudie zeigt, wie ein Domainmodell mit fixiertem Kurs im Aggregat Probleme löst, die in theoretischen DDD-Beispielen nicht erkennbar sind.
Das Problem von drei Wechselkursen in einer Zahlung
Die ursprüngliche Architektur holte Kurse aus mehreren Quellen: checkout-api aus Redis, payment-service via HTTP von fx-rate-service und ledger-service aus einem Postgres-Snapshot. Dadurch entstanden Abweichungen: Der Kunde sah 12.430,00 KZT, die Bank berechnete 12.435,27 KZT und das Support-Tool im Admin-Panel zeigte 12.428,91 KZT. Jeder Service war technisch korrekt, das System insgesamt aber inkonsistent.
Die Übergangslösung – Cache-Deaktivierung in checkout-api bei großen Zahlungen – verschlechterte die p95-Latenz von 120 ms auf 500 ms. Die dauerhafte Lösung führte eine Domänenentität Quote ein, die Kurs, Betrag und Gültigkeitsdauer als Geschäftsvertrag fixiert. Zahlungen werden nun über quote_id erstellt statt dynamischer Daten. Das schloss eine Neuberechnung des Kurses während der Abbuchung oder Ledger-Eintragung aus.
type Quote struct {
ID string
UserID string
From Money
To Money
Rate RateSnapshot
CreatedAt time.Time
}
func NewPayment(quote Quote, idemKey string) (*Payment, error) {
if time.Now().After(quote.Rate.ExpiresAt) {
return nil, ErrQuoteExpired
}
// ...
}
Die payments-Tabelle wurde denormalisiert (Speicherung von Kopien von Kursen und Beträgen), aber das gewährleistet Datenatomizität für Audits. Eine einzelne Zeile enthält nun alle Infos zur Analyse eines Vorfalls.
Rundungsfehler: float64 vs decimal.Decimal
Der zweite kritische Bug zeigte sich durch sich anhäufende Abweichungen über 8.000 Zeilen hinweg über Nacht. Die Ursache war inkonsistente Rundung: payment-service nutzte math.Round, billing-service decimal.NewFromFloat und Reports SQLs round()-Funktion. Die Umwandlung von float64 zu decimal via NewFromFloat führte zu Präzisionsverlusten.
Die Übergangslösung war ein nächtlicher Job rounding_adjustments, der Abweichungen bis 1 KZT ansammelte. Die finale Lösung umfasste:
- Verbot von
float64in Domänenstrukturen - Annahme von Beträgen als Strings via
decimal.NewFromString - Zentralisierte Rundung in der
Money.RoundByCurrency-Methode - Bindung der Präzision an die Währung (z. B. JPY auf ganze Zahlen)
func (m Money) RoundByCurrency() Money {
scale := map[Currency]int32{
"USD": 2,
"JPY": 0,
}[m.currency]
return Money{
amount: m.amount.Round(scale),
currency: m.currency,
}
}
Die Performance bei Batch-Operationen sank von 1,8 auf 6,4 Sekunden für 200.000 Zeilen, was für Backoffice-Prozesse akzeptabel ist. Genauigkeit ging vor Geschwindigkeit.
Idempotenz vs. Timeouts externer APIs
Das dritte Problem entstand beim Zahlungsanbieter: Ein 3-Sekunden-Timeout in acquirer-api führte zu doppelten Abbuchungen. Bei context deadline exceeded wiederholte das System den Aufruf, der erste Request wurde aber trotzdem vom Anbieter verarbeitet. Ergebnis: Doppelte Belastung mit demselben Betrag.
Der erste Workaround – automatische Wiederholungen deaktivieren – erhöhte Zahlungen im unknown-Status. Die finale Lösung:
- Pflicht-
idempotency_keymit unique index auf(merchant_id, idempotency_key) - Domänenmethode
Authorize, die den aktuellen Zahlungsstatus prüft - Reconciliation-Worker für hängende Operationen
create unique index payments_idem_uq
on payments (merchant_id, idempotency_key);
func (p *Payment) Authorize(acquirerID string) error {
switch p.Status {
case PaymentAuthorized:
return nil
case PaymentCreated, PaymentUnknown:
p.Status = PaymentAuthorized
// ...
}
}
Der PaymentUnknown-Status wurde zu einem expliziten Systemzustand für Unsicherheiten externer APIs. Der Reconciliation-Worker bearbeitet Zahlungen älter als 90 Sekunden, indem er den Status beim Anbieter abfragt.
Verzögerung des Lese-Modells: Echtzeitdaten für Support
Nach Einführung von Quote tauchte ein neues Problem auf: Das Support-Admin-Panel zeigte veraltete Daten durch Kafka-Verzögerung (3–4 Minuten). Obwohl Zahlungen korrekt waren, sahen Operatoren alte Beträge, was Panik bei Kunden auslöste.
Schnelle Fixes:
- Anzeige von
quote_idundrate_versionim Admin-Panel - Direkte Abfragen der
payments-Tabelle für Zahlungen unter 15 Minuten alt - Senkung der Verzögerungs-Alarmschwelle von 10 auf 60 Sekunden
Die finale Lösung trennte Datenquellen:
- Für aktuelle Zahlungen: die
payments-Tabelle - Für historische Daten: das ledger-basierte Lese-Modell
Das erhöhte die Komplexität im Admin-Panel, sicherte aber Frische der Daten in kritischen Szenarien.
DDD ohne Dogma: Das Aggregat als Kern der Domäne
Der Erfolg des Projekts lag nicht in einem domain-Ordner, sondern in der Neubetrachtung des Wechselkurses als Geschäftsvertrag. Das Payment-Aggregat
- prüft
Quote-Ablauf bei Erstellung - fixiert den Betrag nach Bestätigung
- schließt
float64aus Domänenoperationen aus - gewährleistet idempotente Abbuchungen
- handhabt
unknown-Unsicherheiten korrekt
Die Anwendungsschicht wurde dünn: Sie orchestriert nur Schritte und delegiert Geschäftslogik ans Aggregat. Beispiel:
func (uc *UseCase) CreatePayment(ctx context.Context, cmd CreatePaymentCommand) (*Payment, error) {
quote, err := uc.quotes.Get(ctx, cmd.QuoteID)
if err != nil {
return nil, err
}
payment, err := NewPayment(*quote, cmd.IdempotencyKey)
// ...
}
Haupterkenntnis: DDD funktioniert, wenn Domänenregeln im Modell verankert sind, nicht als technische Details behandelt werden.
Wichtige Erkenntnisse
- Fixierter Kurs im Aggregat eliminiert inter-service Abweichungen. Der Kurs muss Teil des Zahlungsvertrags sein, kein dynamischer Parameter.
- Präzise Geldtypen.
decimal.Decimalmit zentraler Rundung nutzen;float64auch in Tests vermeiden. - Expliziter
unknown-Zustand spiegelt Unsicherheiten externer Systeme ehrlich wider. Reconciliation-Worker für manuelle Auflösung hinzufügen. - Getrennte Datenquellen sind für Support-Operations entscheidend. Aktuelle Operationen direkt aus der Tabelle lesen, nicht aus dem Lese-Modell.
— Editorial Team
Noch keine Kommentare.