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

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

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

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

checkout-api
payment-service
fx-rate-service
billing-service
ledger-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) / 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.

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

Комментарии (7)


  1. pvlbgtrv
    19.04.2026 12:49

    тот случай когда 1 чел на пыхе сделал бы надежный сервис, вместо этого трое пилят микросервисы и redis кеш под 20-30 платежей в минуту)

    float64 ваще имба, банда скилфактори в деле


    1. Dhwtj
      19.04.2026 12:49

      Да, странно что в конце статьи нет “приходите на наши недельные курсы”