惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Secure Thoughts
T
The Exploit Database - CXSecurity.com
C
CERT Recently Published Vulnerability Notes
P
Palo Alto Networks Blog
T
Threatpost
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
T
Tenable Blog
F
Full Disclosure
TaoSecurity Blog
TaoSecurity Blog
I
InfoQ
AI
AI
云风的 BLOG
云风的 BLOG
K
Kaspersky official blog
Microsoft Azure Blog
Microsoft Azure Blog
S
SegmentFault 最新的问题
Y
Y Combinator Blog
GbyAI
GbyAI
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
P
Privacy International News Feed
aimingoo的专栏
aimingoo的专栏
罗磊的独立博客
酷 壳 – CoolShell
酷 壳 – CoolShell
I
Intezer
L
Lohrmann on Cybersecurity
博客园_首页
量子位
Blog — PlanetScale
Blog — PlanetScale
The Last Watchdog
The Last Watchdog
Cisco Talos Blog
Cisco Talos Blog
博客园 - 叶小钗
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Help Net Security
Help Net Security
Security Archives - TechRepublic
Security Archives - TechRepublic
Apple Machine Learning Research
Apple Machine Learning Research
W
WeLiveSecurity
N
News and Events Feed by Topic
S
Schneier on Security
T
Tor Project blog
MongoDB | Blog
MongoDB | Blog
博客园 - 三生石上(FineUI控件)
V
Visual Studio Blog
T
Threat Research - Cisco Blogs
H
Help Net Security
V
V2EX
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
S
Security @ Cisco Blogs
博客园 - Franky
有赞技术团队
有赞技术团队
Martin Fowler
Martin Fowler

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
DDD в Go без красивых схем: как один платеж получил три курса валют
Goodycon · 2026-04-19 · via Все публикации подряд на Хабре

Средний

8 мин

2.3K

Кейс

Писать про 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.

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