Zpět na domů

DDD v Go: jak se vyhnout chybám s kurzy měn v platbách

Analýza čtyř kritických chyb v platebním systému na Go. Ukázáno, jak implementace DDD s fixací kurzu měny v doménovém agregátu řeší problémy nesouladů, chyb zaokrouhlování a dvojitých strhů. Navržena konkrétní technická řešení s příklady kódu.

Jak jedna platba získala tři kurzy: lekce DDD v reálném platebním systému
Advertisement 728x90

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í.

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

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:

Google AdInline article slot
  • Zákaz float64 v 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í:

Google AdInline article slot
  • Povinný idempotency_key s 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_id a rate_version v admince
  • Přímé čtení z tabulky payments pro 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 Quote při vytváření
  • Blokuje přepočet částky po potvrzení
  • Vylučuje float64 z 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.Decimal s centralizovaným zaokrouhlováním, vyhněte se float64 i v testovacích příkladech.
  • Explicitní stav unknown upří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

Advertisement 728x90

Číst dál