Писать про DDD легко, пока в примерах User, Order и пара красивых стрелочек. В проде оно обычно выглядит менее аккуратно: у клиента в интерфейсе одна сумма, списывается другая, саппорт открывает админку и видит третью.
Расскажу про платежный кусок, где мы на Go в какой-то момент уперлись в курсы валют. Названия сервисов чуть изменены, но суть та же. Это не история про “как мы построили идеальную архитектуру”. Скорее наоборот: сначала сделали нормально на вид, потом оно начало протекать в самых неприятных местах.
Как было в первой версии
Сервисов было немного:
checkout-api payment-service fx-rate-service billing-service ledger-service
Поток простой:
checkout-apiпоказывает клиенту сумму.payment-serviceсоздает платеж.billing-serviceсписывает деньги.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) / 100 if 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 по HTTP ledger-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 string type Money struct { amount decimal.Decimal currency Currency } type RateSnapshot struct { Pair string Value decimal.Decimal Source string Version int64 CreatedAt time.Time ExpiresAt time.Time } type Quote struct { ID string UserID string From Money To Money Rate RateSnapshot CreatedAt 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_id quote_id debit_amount debit_currency credit_amount credit_currency rate_pair rate_value rate_version rate_expires_at status idempotency_key
С точки зрения нормализации так себе. С точки зрения разбора инцидента через месяц - отлично. Можно открыть одну строку и понять, почему клиенту списали именно столько.
Баг второй: float64 прошел ревью, но не прошел деньги
Следующая проблема была уже банальная. Где-то у нас был float64, где-то numeric(18, 6), где-то decimal.Decimal.
Симптом:
expected ledger amount: 12435.27 actual ledger amount: 12435.26 diff: 0.01
Один тенге или одна копейка - звучит смешно, пока таких строк не 8 000 за ночь.
Проблема была в том, что округляли в трех местах:
// payment-service amount := math.Round(raw*100) / 100
// billing-service amount := decimal.NewFromFloat(raw).Round(2)
-- nightly report round(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 exceeded retry after 500ms POST /charge -> 200 OK
Что видел клиент в банке:
Списание 12 435.27 KZT Списание 12 435.27 KZT
Первый запрос не умер. Он просто ответил позже. А мы уже отправили второй.
В логах это было так:
payment_id=pay_9921 attempt=1 timeout_ms=3000 err=deadline_exceeded payment_id=pay_9921 attempt=2 status=authorized acquirer_id=acq_771 payment_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_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 p.AcquirerID = acquirerID return nil default: return ErrInvalidPaymentState } }
И да, PaymentUnknown - не самый приятный статус. Но он честный. Внешний мир иногда не дает тебе бинарного ответа “успешно/неуспешно”.
reconciliation-worker запускался каждую минуту и добирал платежи старше 90 секунд:
select payment_id, acquirer_request_id from payments where 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 table historical/report view: source = ledger read model
Не идеально, потому что в админке появилось две ветки чтения. Но это лучше, чем показывать саппорту устаревшие данные и говорить “ну оно сейчас доедет”.
Где здесь DDD, если без религии
DDD помог не папкой domain. Папку можно назвать как угодно и все равно написать кашу.
Польза была в другом: мы перестали считать курс валют технической деталью.
Раньше правда была размазана:
курс в Redis курс в fx-rate-service курс в Postgres сумма в billing сумма в ledger
После изменений правда стала ближе к платежу:
type Payment struct { ID string MerchantID string UserID string QuoteID string Debit Money Credit Money Rate RateSnapshot Status PaymentStatus IdempotencyKey string AcquirerID 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_id quote_id rate_version idempotency_key acquirer_request_id trace_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.
Не идеальная архитектура. Просто после нее стало меньше ночных сверок, меньше ручных корректировок и меньше вопросов вида: “а почему клиент увидел одну сумму, а списали другую?”
pvlbgtrv
тот случай когда 1 чел на пыхе сделал бы надежный сервис, вместо этого трое пилят микросервисы и redis кеш под 20-30 платежей в минуту)
float64 ваще имба, банда скилфактори в деле
Dhwtj
Да, странно что в конце статьи нет “приходите на наши недельные курсы”