Zurück zur Startseite

DDD in Go: Wie man Fehler bei Wechselkursen in Zahlungen vermeidet

Analyse von vier kritischen Bugs in einem Zahlungssystem auf Go. Sie zeigt, wie die Implementierung von DDD mit Wechselkursfixierung im Domain-Aggregat Abweichungsprobleme, Rundungsfehler und doppelte Abbuchungen löst. Spezifische technische Lösungen mit Code-Beispielen werden vorgeschlagen.

Wie eine Zahlung drei Kurse erhielt: DDD-Lektionen in einem realen Zahlungssystem
Advertisement 728x90

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.

Google AdInline article slot
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:

Google AdInline article slot
  • Verbot von float64 in 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:

Google AdInline article slot
  • Pflicht-idempotency_key mit 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_id und rate_version im 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 float64 aus 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.Decimal mit zentraler Rundung nutzen; float64 auch 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

Advertisement 728x90

Weiterlesen