DDD v boji: jak fixace kurzu měny v platbě uzavřela čtyři kritické bugy
V platebních systémech chyby v výpočtech kurzů měn vedou k finančním nesrovnalostem a ztrátě důvěry. Reálný případ na Go demonstruje, jak doménový model s fixovaným kurzem v agregátu řeší problémy, které nejsou vidět v učebních příkladech DDD.
Problém tří kurzů měn v jedné platbě
Původní architektura používala několik zdrojů kurzů: checkout-api bral data z Redis, payment-service přes HTTP z fx-rate-service a ledger-service ze snímku v Postgres. To vedlo k nesrovnalostem: klient viděl 12 430.00 KZT, banka strhla 12 435.27 KZT a support v admince pozoroval 12 428.91 KZT. Každá služba byla technicky správná, ale systém celkově generoval nesouladná data.
Dočasné řešení – vypnutí cache v checkout-api pro velké platby – zhoršilo p95 latency z 120 ms na 500 ms. Trvalé řešení vyžadovalo zavedení doménové entity Quote, která fixuje kurz, částku a dobu platnosti jako obchodní smlouvu. Nyní se platba vytváří podle quote_id, ne podle dynamických dat. To vyloučilo možnost přepočtu kurzu v fázi stržení nebo zápisu do účetnictví.
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
}
// ...
}
Tabulka payments se stala nadbytečnou z hlediska normalizace (ukládá kopie kurzu a částek), ale zajistila atomaritu dat pro audit. Jedna řada nyní obsahuje všechny informace pro rozbor incidentu.
Chyby zaokrouhlování: float64 proti decimal.Decimal
Druhá kritická chyba se projevila nahromaděním nesrovnalostí až do 8 000 řádků za noc. Problém vznikl kvůli nesouladnému zaokrouhlování: v payment-service se používal math.Round, v billing-service decimal.NewFromFloat a v reportech SQL funkce round(). Konverze float64 na decimal přes NewFromFloat vedla ke ztrátě přesnosti.
Dočasným řešením se stal noční job rounding_adjustments, který hromadil nesrovnalosti do 1 KZT. Finální řešení zahrnovalo:
- Zákaz
float64v doménových strukturách - Přijímání částek jako řetězec přes
decimal.NewFromString - Centralizované zaokrouhlování v metodě
Money.RoundByCurrency - Vázání přesnosti na měnu (např. JPY se zaokrouhluje na celé číslo)
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,
}
}
Výkon dávkových operací klesl z 1,8 na 6,4 sekundy na 200k řádků, ale je to přijatelné pro backoffice procesy. Priorita byla přesnost před rychlostí.
Idempotence proti timeoutům externích API
Třetí problém nastal s poskytovatelem plateb: timeout 3 sekundy v acquirer-api vedl k opakovaným stržením. Při context deadline exceeded systém poslal opakovaný požadavek, ale první požadavek byl zpracován poskytovatelem. Výsledek – dvojité stržení stejné částky.
První záplatou bylo vypnutí automatických retry – což zvýšilo počet plateb ve stavu unknown. Finální řešení:
- Povinný
idempotency_keys unikátním indexem(merchant_id, idempotency_key) - Doménová metoda
Authorize, která kontroluje aktuální stav platby - Reconciliation worker pro zaseknuté operace
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
// ...
}
}
Stav PaymentUnknown se stal explicitním stavem systému, který odráží neurčitost externího API. Reconciliation worker zpracovává platby starší 90 sekund a dotazuje se na stav u poskytovatele.
Zpoždění read model: data pro support v reálném čase
Po zavedení Quote vznikl nový problém: adminka supportu ukazovala zastaralá data kvůli zpoždění Kafka (3–4 minuty). Ačkoli platba byla správná, operátoři viděli starou částku, což vyvolávalo paniku u klientů.
Rychlá opatření:
- Zobrazení
quote_idarate_versionv admince - Přímé čtení z tabulky
paymentspro platby mladší 15 minut - Snížení prahu alertu zpoždění z 10 na 60 sekund
Finální řešení oddělilo zdroje dat:
- Pro čerstvé platby – tabulka
payments - Pro historická data – read model na bázi ledger
To přidalo složitost do adminky, ale zaručilo aktuálnost dat v kritických scénářích.
DDD bez náboženství: agregát jako centrum domény
Úspěch projektu není v složce domain, ale v přehodnocení kurzu měny jako obchodní smlouvy. Agregát Payment nyní:
- Kontroluje platnost
Quotepři vytváření - Blokuje přepočet částky po potvrzení
- Vylučuje
float64z doménových operací - Zaručuje idempotenci stržení
- Správně zpracovává neurčitý stav
unknown
Vrstva aplikace se stala tenkou: pouze spojuje kroky a deleguje obchodní logiku do agregátu. Příklad použití:
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)
// ...
}
Klíčový závěr: DDD funguje, když se doménová pravidla stanou nedílnou součástí modelu, ne technickou detaily.
Co je důležité
- Fixovaný kurz v agregátu vylučuje nesrovnalosti mezi službami. Kurz musí být součástí smlouvy platby, ne dynamickým parametrem.
- Přesné typy pro peníze. Používejte
decimal.Decimals centralizovaným zaokrouhlováním, vyhněte sefloat64i v testovacích příkladech. - Explicitní stav
unknownupřímně odráží neurčitost externích systémů. Přidejte reconciliation worker pro ruční řešení takových případů. - Oddělení zdrojů dat je klíčové pro operativní práci supportu. Pro čerstvé operace čtěte přímo z tabulky, ne z read model.
— Editorial Team
Zatím žádné komentáře.