Powrót do strony głównej

DDD w Go: jak uniknąć błędów z kursami walut w płatnościach

Analiza czterech krytycznych bugów w systemie płatniczym na Go. Pokazano, jak wdrożenie DDD z fiksacją kursu waluty w domenowym agregacie rozwiązuje problemy rozbieżności, błędów zaokrąglania i podwójnych obciążeń. Zaproponowano konkretne techniczne rozwiązania z przykładami kodu.

Jak jedna płatność otrzymała trzy kursy: lekcje DDD w rzeczywistym systemie płatniczym
Advertisement 728x90

# DDD w boju: jak fixacja kursu waluty w płatności zamknęła cztery krytyczne błędy

W systemach płatniczych błędy w obliczeniach kursów walut prowadzą do rozbieżności finansowych i utraty zaufania. Rzeczywisty przypadek na Go pokazuje, jak model domenowy z stałym kursem w agregacie rozwiązuje problemy, których nie widać w edukacyjnych przykładach DDD.

Problem trzech kursów walut w jednej płatności

Początkowa architektura korzystała z kilku źródeł kursów: checkout-api pobierał dane z Redis, payment-service — przez HTTP z fx-rate-service, a ledger-service — ze snapshotu w Postgres. To powodowało rozbieżności: klient widział 12 430.00 KZT, bank pobierał 12 435.27 KZT, a support w panelu admina obserwował 12 428.91 KZT. Każdy serwis działał technicznie poprawnie, ale system jako całość generował niespójne dane.

Tymczasowe rozwiązanie — wyłączenie cache w checkout-api dla dużych płatności — pogorszyło p95 latency z 120 ms do 500 ms. Stała poprawka wymagała wprowadzenia encji domenowej Quote, która fixuje kurs, kwotę i czas życia jako kontrakt biznesowy. Teraz płatność jest tworzona na podstawie quote_id, a nie dynamicznych danych. To wyeliminowało możliwość przeliczania kursu na etapie pobierania lub księgowania.

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
	}
	// ...
}

Tabela payments stała się nadmiarowa pod względem normalizacji (przechowuje kopie kursu i kwot), ale zapewniła atomowość danych dla audytu. Jedna wiersz teraz zawiera wszystkie informacje potrzebne do analizy incydentu.

Błędy zaokrąglania: float64 kontra decimal.Decimal

Drugi krytyczny błąd ujawnił się w kumulacji rozbieżności do 8 000 wierszy na noc. Problem wynikał z niespójnego zaokrąglania: w payment-service używano math.Round, w billing-service — decimal.NewFromFloat, a w raportach — funkcji SQL round(). Konwersja float64 na decimal przez NewFromFloat powodowała utratę precyzji.

Tymczasowym rozwiązaniem stał się nightly job rounding_adjustments, akumulujący rozbieżności do 1 KZT. Ostateczne rozwiązanie obejmowało:

Google AdInline article slot
  • Zakaz float64 w strukturach domenowych
  • Przyjmowanie kwot jako string przez decimal.NewFromString
  • Centralizowane zaokrąglanie w metodzie Money.RoundByCurrency
  • Powiązanie precyzji z walutą (np. JPY zaokrągla się do liczb całkowitych)
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,
	}
}

Wydajność operacji batch spadła z 1.8 do 6.4 sekundy na 200k wierszy, ale to akceptowalne dla procesów backoffice. Priorytetem była precyzja nad prędkością.

Idempotentność kontra timeouty zewnętrznych API

Trzeci problem pojawił się z dostawcą płatności: timeout 3 sekund w acquirer-api prowadził do podwójnych pobrań. Przy context deadline exceeded system wysyłał powtarzany request, ale pierwszy był jednak przetwarzany przez dostawcę. Rezultat — podwójne pobranie tej samej kwoty.

Pierwszy plaster — wyłączenie automatycznych retry — zwiększyło liczbę płatności w statusie unknown. Ostateczne rozwiązanie:

Google AdInline article slot
  • Obowiązkowy idempotency_key z unikalnym indeksem (merchant_id, idempotency_key)
  • Metoda domenowa Authorize, sprawdzająca aktualny status płatności
  • Reconciliation worker dla zawieszonych operacji
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
		// ...
	}
}

Status PaymentUnknown stał się jawnym stanem systemu, odzwierciedlającym niepewność zewnętrznego API. Reconciliation worker przetwarza płatności starsze niż 90 sekund, pytając o status u dostawcy.

Opóźnienie read model: dane dla supportu w czasie rzeczywistym

Po wdrożeniu Quote pojawił się nowy problem: panel supportu pokazywał nieaktualne dane z powodu opóźnienia Kafka (3-4 minuty). Chociaż płatność była poprawna, operatorzy widzieli starą kwotę, co powodowało panikę u klientów.

Szybkie działania:

  • Wyświetlenie quote_id i rate_version w panelu admina
  • Bezpośrednie czytanie z tabeli payments dla płatności młodszych niż 15 minut
  • Obniżenie progu alertu lag z 10 do 60 sekund

Ostateczne rozwiązanie podzieliło źródła danych:

  • Dla świeżych płatności — tabela payments
  • Dla danych historycznych — read model oparty na ledger

To dodało złożoności w panelu admina, ale zagwarantowało aktualność danych w krytycznych scenariuszach.

DDD bez religii: agregat jako centrum domeny

Sukces projektu nie tkwi w folderze domain, lecz w reinterpretacji kursu waluty jako kontraktu biznesowego. Agregat Payment teraz:

  • Sprawdza ważność Quote przy tworzeniu
  • Blokuje przeliczanie kwoty po potwierdzeniu
  • Wyklucza float64 z operacji domenowych
  • Gwarantuje idempotentność pobrań
  • Poprawnie obsługuje nieokreślony stan unknown

Warstwa aplikacji stała się cienka: jedynie łączy kroki, delegując logikę biznesową do agregatu. Przykład użycia:

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)
	// ...
}

Kluczowy wniosek: DDD działa, gdy reguły domenowe stają się nieodłączną częścią modelu, a nie technicznym detalem.

Co ważne

  • Stały kurs w agregacie eliminuje rozbieżności między serwisami. Kurs musi być częścią kontraktu płatności, a nie parametrem dynamicznym.
  • Precyzyjne typy dla pieniędzy. Używaj decimal.Decimal z centralizowanym zaokrąglaniem, unikaj float64 nawet w przykładach testowych.
  • Jawny stan unknown uczciwie odzwierciedla niepewność zewnętrznych systemów. Dodaj reconciliation worker do ręcznego rozstrzygania takich przypadków.
  • Podział źródeł danych jest kluczowy dla bieżącej pracy supportu. Dla świeżych operacji czytaj bezpośrednio z tabeli, a nie z read model.

— Editorial Team

Advertisement 728x90

Czytaj dalej