DDD в Go без красивых схем: как один платеж получил три курса валют

от автора

Писать про DDD легко, пока в примерах User, Order и пара красивых стрелочек. В проде оно обычно выглядит менее аккуратно: у клиента в интерфейсе одна сумма, списывается другая, саппорт открывает админку и видит третью.

Расскажу про платежный кусок, где мы на Go в какой-то момент уперлись в курсы валют. Названия сервисов чуть изменены, но суть та же. Это не история про “как мы построили идеальную архитектуру”. Скорее наоборот: сначала сделали нормально на вид, потом оно начало протекать в самых неприятных местах.

Как было в первой версии

Сервисов было немного:

checkout-apipayment-servicefx-rate-servicebilling-serviceledger-service

Поток простой:

  1. checkout-api показывает клиенту сумму.

  2. payment-service создает платеж.

  3. billing-service списывает деньги.

  4. ledger-service пишет проводку.

На старте трафик был смешной: 20-30 платежей в минуту, p95 по payment-service около 180 мс. Курсы валют обновлялись раз в 5 минут. Вроде бы вообще не место, где должно быть больно.

Первая версия кода была примерно такая:

type CreatePaymentRequest struct {UserID      string  `json:"user_id"`MerchantID  string  `json:"merchant_id"`Amount      float64 `json:"amount"`Currency    string  `json:"currency"`Target      string  `json:"target_currency"`}func (s *Service) CreatePayment(ctx context.Context, req CreatePaymentRequest) error {rate, err := s.fx.GetRate(ctx, req.Currency, req.Target)if err != nil {return err}amountToCharge := math.Round(req.Amount*rate*100) / 100if err := s.billing.Charge(ctx, req.UserID, amountToCharge, req.Target); err != nil {return err}return s.ledger.Append(ctx, LedgerEntry{UserID:   req.UserID,Amount:   amountToCharge,Currency: req.Target,})}

На ревью это не выглядело ужасно. Да, float64 для денег пахнет плохо, но “пока быстро запустим, потом поправим”. Знакомая фраза, да.

Потом пошли первые сверки.

Баг первый: у платежа оказалось три курса

В какой-то момент финансы прислали таблицу на 47 строк. Там были расхождения по платежам: 2-5 тенге на обычных заказах и до 180 тенге на крупных.

Для пользователя это выглядело тупо:

В интерфейсе: 12 430.00 KZTВ SMS от банка: 12 435.27 KZTВ админке саппорта: 12 428.91 KZT

Сначала подумали на округление. Это была хорошая гипотеза, но не вся правда.

Оказалось веселее:

checkout-api     -> Redis, TTL 5 минутpayment-service  -> fx-rate-service по HTTPledger-service   -> таблица fx_rates_snapshot в Postgres

В рамках одной операции участвовали три разных курса.

В логах каждый сервис был “прав”:

{  "service": "checkout-api",  "payment_id": "pay_7f31",  "pair": "USD/KZT",  "rate": "448.12",  "source": "redis",  "rate_age_sec": 241}
{  "service": "payment-service",  "payment_id": "pay_7f31",  "pair": "USD/KZT",  "rate": "448.31",  "source": "fx-rate-service",  "latency_ms": 96}
{  "service": "ledger-service",  "payment_id": "pay_7f31",  "pair": "USD/KZT",  "rate": "448.08",  "source": "postgres_snapshot",  "snapshot": "2026-04-14T09:00:00Z"}

Отдельно ни один сервис не врал. Но система в целом врала клиенту.

Временный костыль был максимально приземленный: на два дня отключили кеш курса в checkout-api для валютных платежей выше 50 000 KZT. Да, стало медленнее: p95 checkout вырос с 120 мс до 430-500 мс. Зато саппорт перестал получать новые тикеты пачками.

Нормальный фикс был другой: курс должен быть частью платежа, а не чем-то, что каждый сервис достает как хочет.

Quote как часть домена, а не DTO для фронта

Мы ввели Quote. Не как “ответ ручки для UI”, а как зафиксированное бизнес-решение: вот эта сумма, вот этот курс, вот срок жизни.

type Currency stringtype Money struct {amount   decimal.Decimalcurrency Currency}type RateSnapshot struct {Pair      stringValue     decimal.DecimalSource    stringVersion   int64CreatedAt time.TimeExpiresAt time.Time}type Quote struct {ID        stringUserID    stringFrom      MoneyTo        MoneyRate      RateSnapshotCreatedAt time.Time}

Платеж теперь создавался не из amount + currency, а из quote_id.

func NewPayment(quote Quote, idemKey string) (*Payment, error) {if idemKey == "" {return nil, ErrEmptyIdempotencyKey}if time.Now().After(quote.Rate.ExpiresAt) {return nil, ErrQuoteExpired}return &Payment{ID:             newPaymentID(),UserID:         quote.UserID,QuoteID:        quote.ID,Debit:          quote.From,Credit:         quote.To,Rate:           quote.Rate,Status:         PaymentCreated,IdempotencyKey: idemKey,}, nil}

С этого момента billing-service и ledger-service больше не имели права “уточнить курс”. Они получали уже рассчитанную сумму и rate_version.

Таблица payments стала менее красивой:

payment_idquote_iddebit_amountdebit_currencycredit_amountcredit_currencyrate_pairrate_valuerate_versionrate_expires_atstatusidempotency_key

С точки зрения нормализации так себе. С точки зрения разбора инцидента через месяц — отлично. Можно открыть одну строку и понять, почему клиенту списали именно столько.

Баг второй: float64 прошел ревью, но не прошел деньги

Следующая проблема была уже банальная. Где-то у нас был float64, где-то numeric(18, 6), где-то decimal.Decimal.

Симптом:

expected ledger amount: 12435.27actual ledger amount:   12435.26diff: 0.01

Один тенге или одна копейка — звучит смешно, пока таких строк не 8 000 за ночь.

Проблема была в том, что округляли в трех местах:

// payment-serviceamount := math.Round(raw*100) / 100
// billing-serviceamount := decimal.NewFromFloat(raw).Round(2)
-- nightly reportround(amount * rate, 2)

И вот это decimal.NewFromFloat(raw) потом отдельно вспоминали нехорошими словами.

Временное решение: сделали nightly job, который складывал расхождения до 1 KZT в отдельную таблицу rounding_adjustments. Некрасиво. Зато отчеты перестали падать каждое утро, пока мы вычищали код.

Нормальное решение:

  • запретили float64 в доменных структурах;

  • суммы начали принимать строкой;

  • округление привязали к валюте;

  • все расчеты вынесли в Money.

func NewMoney(raw string, currency Currency) (Money, error) {amount, err := decimal.NewFromString(raw)if err != nil {return Money{}, err}if amount.IsNegative() {return Money{}, ErrNegativeAmount}return Money{amount: amount, currency: currency}, nil}func (m Money) Convert(rate decimal.Decimal, target Currency) Money {return Money{amount:   m.amount.Mul(rate),currency: target,}}func (m Money) RoundByCurrency() Money {scale := map[Currency]int32{"USD": 2,"EUR": 2,"KZT": 2,"JPY": 0,}[m.currency]return Money{amount:   m.amount.Round(scale),currency: m.currency,}}

Не идеально: decimal медленнее. На batch-пересчете 200k строк время выросло примерно с 1.8 секунды до 6.4. Но это был backoffice job, а не горячая ручка. Решили, что лучше медленно и правильно, чем быстро и потом объяснять финдиректору расхождения.

Баг третий: таймаут не значит, что платеж не прошел

Еще один неприятный эпизод был с внешним провайдером.

payment-service ходил в acquirer-api с таймаутом 3 секунды. Обычно ответ приходил за 400-700 мс. Но пару раз в день p99 улетал до 5-8 секунд.

Что видели мы:

POST /charge -> context deadline exceededretry after 500msPOST /charge -> 200 OK

Что видел клиент в банке:

Списание 12 435.27 KZTСписание 12 435.27 KZT

Первый запрос не умер. Он просто ответил позже. А мы уже отправили второй.

В логах это было так:

payment_id=pay_9921 attempt=1 timeout_ms=3000 err=deadline_exceededpayment_id=pay_9921 attempt=2 status=authorized acquirer_id=acq_771payment_id=pay_9921 callback attempt=1 status=authorized acquirer_id=acq_770

Первый костыль: отключили автоматический retry для POST /charge, если нет явного ответа от провайдера. Это сразу снизило риск двойного списания, но увеличило количество платежей в статусе unknown.

Потом сделали нормально, насколько это вообще возможно с внешними API:

  • idempotency_key стал обязательным;

  • (merchant_id, idempotency_key) получил unique index;

  • callback стал проходить через доменный метод Authorize;

  • зависшие платежи ушли в reconciliation job.

create unique index payments_idem_uqon payments (merchant_id, idempotency_key);
func (p *Payment) Authorize(acquirerID string) error {switch p.Status {case PaymentAuthorized:return nilcase PaymentCreated, PaymentUnknown:p.Status = PaymentAuthorizedp.AcquirerID = acquirerIDreturn nildefault:return ErrInvalidPaymentState}}

И да, PaymentUnknown — не самый приятный статус. Но он честный. Внешний мир иногда не дает тебе бинарного ответа “успешно/неуспешно”.

reconciliation-worker запускался каждую минуту и добирал платежи старше 90 секунд:

select payment_id, acquirer_request_idfrom paymentswhere status = 'unknown'  and created_at < now() - interval '90 seconds'limit 500;

Это костыль? Частично. Но без него любая красивая модель ломалась об API, которое иногда возвращало 502, хотя платеж уже был создан.

Баг четвертый: read model отстает, саппорт паникует

После фикса Quote мы думали, что расхождения закончились. Ну да, конечно.

Через неделю саппорт пишет: “у клиента списалось правильно, но в админке старая сумма”. Клиент уже нервничает, оператор тоже.

Оказалось, что ledger-read-model отставал от Kafka на 3-4 минуты после деплоя. Сам платеж был корректный, запись в payments тоже. Но админка смотрела не туда.

Метрика lag тогда была, но алерт стоял на 10 минут. Потому что “меньше не критично”. Оказалось, критично, если этой админкой пользуется саппорт во время платежного инцидента.

Что сделали быстро:

  • в админке рядом с суммой показали quote_id и rate_version;

  • для платежей младше 15 минут начали читать данные напрямую из payments;

  • consumer lag по payment-events опустили в алертах до 60 секунд.

Что сделали потом:

fresh payment view:  source = payments tablehistorical/report view:  source = ledger read model

Не идеально, потому что в админке появилось две ветки чтения. Но это лучше, чем показывать саппорту устаревшие данные и говорить “ну оно сейчас доедет”.

Где здесь DDD, если без религии

DDD помог не папкой domain. Папку можно назвать как угодно и все равно написать кашу.

Польза была в другом: мы перестали считать курс валют технической деталью.

Раньше правда была размазана:

курс в Redisкурс в fx-rate-serviceкурс в Postgresсумма в billingсумма в ledger

После изменений правда стала ближе к платежу:

type Payment struct {ID             stringMerchantID     stringUserID         stringQuoteID        stringDebit          MoneyCredit         MoneyRate           RateSnapshotStatus         PaymentStatusIdempotencyKey stringAcquirerID     string}

Агрегат стал отвечать за простые, но важные вещи:

  • платеж нельзя создать по протухшему Quote;

  • сумму нельзя пересчитать другим курсом после подтверждения;

  • float64 не попадает в домен;

  • повторная авторизация не делает второе списание;

  • unknown — валидное состояние, а не “ну потом разберемся”.

Application layer просто связывает шаги:

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)if err != nil {return nil, err}if err := uc.payments.Save(ctx, payment); err != nil {return nil, err}uc.outbox.Add(ctx, PaymentCreated{PaymentID: payment.ID,QuoteID:   payment.QuoteID,})return payment, nil}

Никакой магии. Просто меньше мест, где можно “чуть-чуть по-своему” посчитать деньги.

Как дебажили

Самое полезное изменение было вообще не архитектурное. Мы протащили нормальную корреляцию:

payment_idquote_idrate_versionidempotency_keyacquirer_request_idtrace_id

До этого лог выглядел так:

charge failed: timeout

Спасибо, очень помогло.

После:

{  "service": "payment-service",  "payment_id": "pay_9921",  "quote_id": "qt_18aa",  "rate_version": 184233,  "idempotency_key": "ord_71_attempt_1",  "acquirer_request_id": "req_770",  "status": "unknown",  "duration_ms": 3000,  "error": "context deadline exceeded"}

С такими логами уже можно спорить не на уровне “мне кажется”, а на уровне конкретного платежа.

Что осталось кривым

Не хочу делать вид, что после этого все стало красиво.

Остались компромиссы:

  • payments хранит RateSnapshot, хотя это дублирование;

  • PaymentUnknown усложнил поддержку статусов;

  • reconciliation-worker иногда догоняет платежи через 2-3 минуты;

  • decimal замедлил batch-расчеты;

  • старые платежи до миграции не всегда имеют rate_version;

  • в админке теперь две модели чтения: свежая и отчетная.

Но зато мы перестали ловить ситуацию, где один сервис говорит “448.12”, второй “448.31”, третий “448.08”, и все трое формально правы.

Вывод

DDD тут пригодился не как набор терминов, а как способ решить конкретную боль: где находится правда о платеже.

Если у вас CRUD на три таблицы, не надо строить храм с агрегатами и доменными событиями. Но если деньги проходят через кеш, внешний API, ledger, отчеты и саппортскую админку, лучше зафиксировать бизнес-факты явно.

В нашем случае такими фактами стали Quote, RateSnapshot, Money и Payment.

Не идеальная архитектура. Просто после нее стало меньше ночных сверок, меньше ручных корректировок и меньше вопросов вида: “а почему клиент увидел одну сумму, а списали другую?”

ссылка на оригинал статьи https://habr.com/ru/articles/1025226/