# 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.
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:
- Zakaz
float64w 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:
- Obowiązkowy
idempotency_keyz 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_idirate_versionw panelu admina - Bezpośrednie czytanie z tabeli
paymentsdla 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ść
Quoteprzy tworzeniu - Blokuje przeliczanie kwoty po potwierdzeniu
- Wyklucza
float64z 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.Decimalz centralizowanym zaokrąglaniem, unikajfloat64nawet w przykładach testowych. - Jawny stan
unknownuczciwie 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
Brak komentarzy.