← назад к разделу

Когда есть бизнес-бриф маркетплейса и нужно написать первую строчку кода — возникает соблазн сразу начать с деталей: какой агрегат у заказа, какие таблицы в базе, как устроена Saga. Это ошибка. До всего этого нужно ответить на более простой вопрос: какие вообще сервисы будут в системе и где между ними граница.

Без этого шага вы получите код, в котором Catalog считает комиссию, Order пишет в каталог, а Payment знает про скидки. Через полгода это переписывание с нуля.

В этой статье — восемь шагов: от брифа до карты сервисов с границами, интеграциями и планом отказов.

Обязательно

Шаг 1. Собрать доменные события

Первый шаг называется Event Storming: берём бриф и выписываем всё, что в системе происходит, в порядке времени. Не «сущности» и не «таблицы» — а события: «покупатель добавил товар в корзину», «продавец подтвердил заказ», «модератор отклонил карточку».

Формат простой: каждое событие — глагол в прошедшем времени. «Заказ создан», «платёж принят», «карточка одобрена».

Из брифа маркетплейса получается несколько десятков событий. Вот главные — они сами собираются в потоки:

  • Каталог: продавец загрузил карточку → модератор одобрил → покупатель добавил в избранное → карточка снята с продажи.
  • Покупка: корзина создана → товар зарезервирован → заказ оплачен → заказ доставлен → покупатель подтвердил.
  • Деньги: платёж принят → деньги в эскроу → выплата ушла продавцу → возврат запрошен.
  • Уведомления: отправить продавцу о новом заказе → отправить покупателю об отгрузке → напомнить о подтверждении.
  • Споры: спор открыт → оператор взял в работу → возврат денег.
  • Авторизация: покупатель залогинился → продавец прошёл двухфакторную проверку.

Эти потоки — будущие границы сервисов. Не каждый поток становится отдельным сервисом, но каждый сервис вырастает из одного-двух потоков.

Шаг 2. Найти границы контекстов

Из событий выделяем Bounded Contexts — части системы, у каждой из которых свой язык. Звучит абстрактно, вот конкретный пример: слово «заказ» в платёжной части означает «финансовый документ с зарезервированными деньгами», а в каталоге — «строка для аналитики». Если этим двум смыслам дать одно имя в одной базе, через год никто не поймёт, что куда.

Для маркетплейса напрашиваются шесть контекстов:

КонтекстЧто внутри
CatalogТовары, карточки, поиск, модерация
OrderКорзина, заказ, статусы, ссылка на резерв
PaymentПлатежи, эскроу, выплаты, комиссия
NotificationКаналы (email/sms/push), шаблоны, отправка
CustomerПокупатели, продавцы, регистрация, авторизация
BackofficeМодерация, споры, аудит действий оператора

Это кандидаты на сервисы. Вопрос «делать ли микросервисы» этот шаг ещё не решает — он только говорит, где естественные линии разлома.

Шаг 3. Решить: монолит или микросервисы

Это отдельное решение, которое зависит от нагрузки, сроков и состава команды. Для маркетплейса исходные данные такие:

  • Нагрузка: десятки тысяч запросов в секунду на чтение каталога, сотни на оформление заказов и примерно столько же на платежи — платёж идёт за каждым заказом. Чтение каталога надо масштабировать независимо.
  • Сроки: первая версия за шесть месяцев, четыре команды разработки.
  • Деньги: Payment обязан быть изолирован — свой контур доступа, свой аудит, свой релизный цикл. Карты мы не храним, только токены шлюза, так что дело не в сертификации по PCI DSS, а в том, что это деньги.

Решение — микросервисы по контекстам, без дробления внутри контекста:

  • Catalog — отдельно, потому что нагрузка на чтение и команда поиска отдельные.
  • Order — отдельно, потому что нельзя терять резервы и платежи.
  • Payment — отдельно, потому что это деньги: узкий доступ, полный аудит, отдельный релизный цикл.
  • Notification — отдельно по нагрузке и интеграциям с внешними провайдерами.
  • Customer и Backoffice — каждый свой сервис, без серьёзной нагрузки.

Что не делаем: не дробим Catalog на Search, Pricing и Reviews ради «правильной архитектуры». Линии разлома идут по событиям и контекстам, не по техническим слоям.

Что проиграл модульный монолит

Шаг называется «монолит или микросервисы», а доводы выше — только за разделение. Честный разбор требует второй половины: что модульный монолит на этих же исходных данных сделал бы лучше, и почему мы всё-таки выбрали не его.

Где монолит выигрывает при четырёх командах и шести месяцах:

ЧтоМонолитМикросервисы
Транзакции между контекстамиодна транзакция базы: резерв, заказ и платёж согласованысага с компенсациями, согласованность с задержкой
Первая версия к срокубыстрее: нет контрактов между сервисами, нет разворачивания шести конвейеровмедленнее на старте на недели
Изменение, задевающее два контекстаодин запрос на слияниесогласование контракта, два выката, совместимость версий
Локальный запуск и отладказапускается целиком одной командойнужны заглушки или поднятие половины системы
Границы можно подвинутьперенос класса между модулямимиграция данных и контракта
Эксплуатацияодин процесс, один журнал, одна панельшесть всего, плюс трассировка
Стоимость инфраструктурызаметно нижевыше на порядок по числу сущностей

Четвёртая строка стоит отдельного внимания: на старте границы почти всегда проведены неточно, и монолит позволяет их двигать дёшево. Микросервисы фиксируют ошибку границы в схеме данных и в контракте, и исправление стоит миграции.

Почему мы всё-таки выбрали разделение — три довода, которых у монолита нет:

  • Разная нагрузка и разное масштабирование. Чтение каталога — десятки тысяч запросов в секунду, оформление — сотни. В монолите масштабируется всё целиком, включая то, чему это не нужно; и тяжёлое чтение каталога делит ресурсы с оформлением заказов.
  • Изоляция контура денег. Узкий доступ, полный аудит, отдельный релизный цикл — это требование не к коду, а к процессу. В монолите «отдельный релизный цикл для платежей» невозможен по определению: выкатывается всё сразу.
  • Независимый выпуск четырёх команд. Шесть месяцев — это срок, на котором общая ветка и общий выкат четырёх команд начинают съедать время: очередь на слияние, общая красная сборка, согласование окон выката.

И честная оговорка про промежуточный вариант. Модульный монолит с жёсткими границами модулей и отдельной базой на модуль — рабочий выбор, если бы не первый и второй довод. Такой монолит разделяется на сервисы позже и дёшево, если границы не протекли. Проверяется это одним правилом, и его стоит держать в голове, даже выбрав микросервисы: модуль обращается к чужому модулю только через объявленный интерфейс, и никогда к чужим таблицам. Нарушение этого правила — то, что превращает монолит в клубок, и то, из-за чего разделение потом становится невозможным.

Шаг 4. Нарисовать карту с границами

Получаем шесть сервисов. Для каждого важно зафиксировать не только что он делает, но и что не делает — это важнее.

Frontend с вебом и мобайлом, шесть backend-сервисов - Catalog, Order, Payment, Customer, Notification, Backoffice - и внешние платёжная система с провайдером email/sms

Ограничения каждого сервиса:

  • Catalog не считает деньги. Цена в карточке — справочная, реальная цена фиксируется в Order при оформлении.
  • Order не ведёт остатки. Остаток и резерв на нём живут в Catalog: Order просит зарезервировать и хранит у себя только ответ — идентификатор резерва и срок, до которого он действует.
  • Payment не знает, что внутри заказа. Только платежи и планы выплат. Заказ для него — внешний идентификатор и сумма, а не корзина с товарами и адресом.
  • Notification ничего не решает. Только отправляет по команде. Логика «когда уведомить» — в источнике события.
  • Customer не хранит товарный профиль продавца. Customer — это identity и авторизация, отдельно от каталога.
  • Backoffice не правит данные напрямую — действия идут через API соответствующего сервиса. Сам Backoffice ведёт журнал действий оператора: кто, когда и что запросил. Как изменились данные — пишет у себя их владелец.

Эти ограничения — самое ценное в карте.

Шаг 5. Определить, кто чем владеет

Каждая сущность имеет ровно одного владельца. Все остальные читают через интеграции, не лезут в чужую базу напрямую.

СущностьВладелецКто читает
Карточка товараCatalogOrder (снимок при оформлении)
Остаток товараCatalogOrder (через резерв)
ЗаказOrderPayment, Notification, Backoffice
Платёж / эскроуPaymentOrder (статус), Backoffice
Выплата продавцуPaymentBackoffice
Профиль покупателяCustomerOrder, Notification
Профиль продавцаCustomerCatalog, Payment, Backoffice
Авторизационный токенCustomerВсе (через JWT)
Шаблон уведомленияNotification—
Аудит действий оператораBackoffice—

Несколько правил, которые отсюда следуют:

Снимок при пересечении границы. Когда Order оформляется, цена и название товара копируются в заказ. Если продавец завтра изменит карточку — уже созданный заказ не меняется. Это правильно: покупатель должен получить то, на что согласился.

Чужой идентификатор, не структура. Order ссылается на товар по productId, не тащит к себе всю карточку. Чужую структуру он не дублирует.

Для отчётов — отдельная модель чтения. Backoffice читает через API или через поток событий, не делает JOIN на чужие базы данных.

Данные, которые нужны всем

Правило «ровно один владелец» ломается на справочниках: категории товаров, ставки комиссии, курсы, список регионов доставки, настройки лимитов. Они нужны половине сервисов, а владелец у них один. Готовых ответов три, и выбирают по тому, как часто данные меняются и что бывает при расхождении.

Тип данныхПримерКак раздаём
Меняются редко, расхождение терпимокатегории, регионы, справочник статусоввладелец публикует, остальные держат копию и обновляют по событию
Меняются редко, расхождение недопустимоставки комиссии, лимитывладелец хранит версии с датой действия, потребитель запрашивает значение на дату и запоминает его в своей записи
Меняются часто, нужны на момент операциикурсы, динамические ценызначение фиксируется в момент операции и хранится в ней; позже не перечитывается

Три правила, которые из этого следуют:

  • Копия — не владение. Сервис, держащий у себя справочник категорий, не может его менять: правка идёт к владельцу, а копия обновляется событием. Это ровно то, что в шаге 6 названо «нет двусторонней синхронизации».
  • Деньги и правила расчёта фиксируются в момент операции. Ставка комиссии, действовавшая при оформлении, хранится в заказе. Иначе изменение ставки задним числом меняет уже закрытые расчёты — и сверка не сходится.
  • У справочника есть владелец, даже если он скучный. Категории принадлежат Catalog, комиссии — Payment, регионы доставки — Order. «Общий сервис справочников» заводят только тогда, когда справочников становится много и у них появляется собственная жизнь (модерация, версии, утверждение) — иначе это лишний сервис на пути каждого запроса.

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

Шаг 6. Выбрать, как сервисы общаются

Четыре варианта взаимодействия, каждый для своего случая:

Синхронные вызовы (REST) — когда нужен ответ прямо сейчас:

  • Web → Catalog (показать карточку товара)
  • Web → Order (создать заказ)
  • Order → Catalog (зарезервировать остаток)
  • Web → Customer (войти в систему)

Асинхронные события через Kafka — когда сервис должен отреагировать, но блокировать вызывающего не нужно:

  • OrderCreated → Notification (отправить уведомление продавцу), Payment (создать платёжное намерение)
  • PaymentCaptured → Order (перевести в статус «оплачен»)
  • ProductCardModerated → Catalog (опубликовать карточку), Notification (сообщить продавцу)

Saga для распределённой операции «оформление заказа» — резерв товара (Catalog) + платёж (Payment) + создание заказа (Order). Если любой шаг упал — остальные откатываются. Подробнее — в спецификации Order Service.

Transactional Outbox — чтобы событие не потерялось. Событие сохраняется в отдельную таблицу в одной транзакции с бизнес-данными. Отдельный процесс периодически читает её и публикует в Kafka. Это гарантирует, что событие не исчезнет, если сервис упал между записью в базу и отправкой в очередь.

Что не делаем:

  • Нет двусторонней синхронизации. Если Catalog изменился — Order узнаёт через снимок или событие, не через «переспроси и обнови у себя».
  • Нет общей базы данных. Даже в начале — сервисы не разделяют схему PostgreSQL.
  • Нет цепочек из пяти синхронных вызовов. Один пользовательский запрос — не больше двух синхронных переходов между сервисами.

Про сагу оформления заказа стоит сказать больше, чем одну строку — это самая сложная часть карты.

Кто оркестратор. Оформление ведёт Order: он же владеет заказом, он же отвечает за результат перед покупателем. Оркестратор — не отдельный сервис, а компонент внутри Order, который знает порядок шагов и хранит состояние процесса в своей базе. Отдельный «сервис саг» здесь не нужен: процесс один, и он про заказ.

Шаги и компенсации. У каждого шага, который меняет чужое состояние, есть обратное действие:

ШагЧто делаетКомпенсация
Резерв остатка в Catalogуменьшает доступный остаток на срокснять резерв (остаток возвращается)
Создание заказа в Orderлокальная записьотменить заказ
Платёжное намерение в Paymentготовит списаниеотменить намерение
Списание в Paymentденьги списанывозврат платежа

Порядок компенсаций — обратный порядку шагов, и это не формальность: снимать резерв надо после того, как отменено намерение, иначе товар освободится, а деньги ещё в процессе списания.

прямой ход Резерв остатка Создание заказа Намерение Списание откат Возврат платежа Отмена намерения Отмена заказа Снятие резерва

Сверху прямой ход саги, снизу откат: компенсации идут в обратном порядке, поэтому первым отменяется списание, а резерв снимается последним.

Что компенсирует резерв. Отдельно стоит заметить, что у резерва есть два механизма отмены, и оба нужны. Явная компенсация — саги, когда шаг упал. И срок жизни резерва — 15 минут, после которых Catalog снимает его сам, не дожидаясь никого. Второй механизм страхует от случая, когда Order умер посреди процесса и компенсацию послать некому. Правило общее: любое удержание чужого ресурса имеет срок, а не только явную отмену.

Что делать, если упала сама компенсация. Это главный вопрос, и ответ на него определяет, работает сага или только выглядит работающей:

  • Компенсация повторяется, а не отменяется. Она обязана быть идемпотентной: повторный «снять резерв» по тому же идентификатору — успешная операция, а не ошибка.
  • Повторы ограничены по времени, а не по числу. Сага держит состояние «компенсирую» и пытается, пока не получится или пока не истечёт срок ожидания.
  • Не получилось за срок — процесс уходит человеку. Заказ помечается требующим разбора, и он попадает в список оператора с полной историей шагов. Это не позор, а штатная ветка: в распределённой системе всегда есть случаи, которые автоматика не закрывает.
  • Деньги — особый случай. Если списание прошло, а компенсация не проходит, приоритет у возврата денег покупателю: он делается даже вручную, а расхождение по товару разбирается потом.
  • Состояние саги видно. Список процессов, застрявших в компенсации, — не отладочная информация, а рабочий экран: без него зависшие заказы обнаруживает покупатель.

Шаг 7. Продумать поведение при отказах

Для каждого синхронного взаимодействия нужен план: что происходит, если сосед недоступен.

ПадаетКто страдаетЧто делаем
CatalogWeb, OrderWeb показывает закешированные карточки. Order отказывает в оформлении — нельзя резервировать без актуального остатка.
OrderWebWeb показывает «оформление недоступно». Платёж не создаётся.
PaymentOrderSaga ставит платёж в ожидание и повторяет, пока не истекли 15 минут резерва. Не уложились — резерв снимается, заказ помечается «истекший».
CustomerВсеJWT проверяется локально по публичному ключу, без обращения к Customer. Только новые входы в систему недоступны.
Notification—Никто не страдает. События остаются в Kafka, уведомления придут позже.
Backoffice—Аудит накапливается в очереди, оператор разбирает позже.

Главный принцип: падение Notification и Backoffice не влияет на покупки. Падение Catalog или Order влияет, но с плавной деградацией — старый кеш, отказ только в новых заказах, а не во всём сайте.

Catalog Кеш витрины Оформления нет Order Витрина жива Оформления нет Payment Повтор 15 минут Заказ истёк Customer JWT на месте Новых входов нет Notification События ждут Письмо позже Backoffice Аудит копится Разбор позже

Слева упавший сервис, дальше подмена и то, что видит покупатель: покупки останавливают только Catalog и Order.

Эти решения принимаются до детального проектирования каждого сервиса. Если спроектировать сервис, не зная его поведение при отказе соседа, придётся переделывать обработку ошибок и механизм повторных попыток.

Частичный отказ — то, что происходит на самом деле. Таблица выше делит мир на «работает» и «упал», а самый частый случай третий: сосед отвечает, но медленно, или отвечает не на все запросы, или подтверждает после того, как мы перестали ждать.

Что происходитПримерЧто делаем
Отвечает медленноCatalog отдаёт карточку за 3 секунды вместо 50 мспредельное время ожидания на каждый вызов, иначе очередь запросов растёт и падаем мы, а не сосед
Часть запросов падаетодна реплика Payment сломана, треть ответов — ошибкаповтор с задержкой; повторять можно только то, что идемпотентно
Отвечает после нашего срокаPayment подтвердил списание через 40 секунд, мы уже отказалисьобязательно принять ответ позже: платёж опознаётся по ключу и либо применяется, либо возвращается
Отвечает неполновернул 80 позиций из 100либо считать это отказом, либо явно показать частичный результат; молча отдавать неполное нельзя
Ошибки нарастаютсосед деградирует, мы усиливаем нагрузку повторамиразмыкатель: перестать звать на время, отвечать деградированно и проверять раз в интервал

Три правила, которые из этого следуют и которые надо решить до проектирования сервисов:

  • У каждого синхронного вызова есть предельное время ожидания, и оно меньше нашего срока ответа. Без этого медленный сосед превращается в наше падение: потоки заняты ожиданием.
  • Повтор разрешён только идемпотентной операции. «Зарезервировать» с ключом — можно, «списать деньги» без ключа — нельзя. Это тот же ключ повтора, что в разделе про Payment.
  • Ответ, пришедший после срока, должен быть обработан. Самая пропускаемая часть: мы отказались ждать, но сосед всё-таки сделал работу. Если такой ответ некуда деть, появляются деньги без заказа и резервы без владельца.

И признак, что план отказов проработан: для каждой стрелки на карте названы предельное время ожидания, поведение при его истечении и то, что происходит с поздним ответом. Если хотя бы для одной стрелки этого нет — там и будет первый ночной инцидент.

Шаг 8. Визуализировать итог

Финальный артефакт — диаграмма уровня контейнеров (C4 Container). Это то, что показывают новому разработчику и на техническом разборе с заказчиком.

Покупатель, продавец и оператор работают с Web и Mobile, за ними шесть сервисов со своими базами - Catalog, Order, Customer, Payment, Notification, Backoffice - и внешние провайдеры

Каждый узел на схеме — отдельный процесс, своя база данных, свой деплой. Что внутри каждого — описывается в его собственной спецификации.

Дальше

После карты сервисов каждый сервис получает детальную спецификацию на нужном уровне сложности:

  • Catalog — CQRS: чтение через Elasticsearch, запись через PostgreSQL.
  • Order — готовая спецификация: три агрегата, Saga, Outbox, управление доступом.
  • Payment — строгая идемпотентность, изоляция контура денег.
  • Notification — готовая спецификация: отправка без привязки к каналу.
  • Customer — OAuth2 + JWT, без сложной доменной логики.
  • Backoffice — CRUD плюс аудит.

Уровень детальности выбирается по сложности домена, а не ради единообразия. Catalog читают миллионы пользователей — нужен CQRS. Backoffice обрабатывает десять запросов в минуту — агрегаты там не нужны.

Раз вся методология построена на шкале зрелости, полезно приписать уровень каждому сервису — иначе «уровень детальности по сложности домена» остаётся общей фразой:

СервисУровеньПочему
Order3настоящие инварианты (резерв, суммы, переходы статусов), три агрегата, процесс с компенсациями
Payment3деньги, идемпотентность, строгие правила; ошибка стоит дороже всего
Catalog2 плюс разделение чтения и записиправил немного, а нагрузка на чтение требует отдельной модели чтения
Notification2операции простые, но их много и нужны метрики, повторы и журнал доставки
Customer2вход и права; доменной логики почти нет, но операции должны быть явными
Backoffice1по сути CRUD с аудитом; агрегаты здесь только добавят файлов

Смысл этой таблицы не в самих цифрах, а в том, что уровни в одной системе разные, и это норма. Попытка привести всё к одному уровню даёт один из двух плохих исходов: либо Backoffice обрастает агрегатами, которые ничего не защищают, либо Payment остаётся без модели, которая ему нужна.

Как карта живёт дальше

Карта, нарисованная один раз и забытая, через полгода врёт — а по ней принимают решения. Поэтому у неё, как у любого артефакта, есть владелец и правила обновления.

Где лежит. В репозитории, рядом с кодом или в отдельном репозитории архитектуры, но обязательно в виде текста и схемы-исходника, а не картинки. Причина простая: картинку нельзя сравнить изменениями и нельзя поправить, не имея исходника. Схема генерируется из текстового описания, и в запросе на слияние видно, что именно поменялось.

Кто обновляет. Тот, кто вносит изменение: добавил сервис — добавил узел и рёбра; изменил интеграцию — поправил стрелку; забрал владение сущностью — поправил таблицу владения. Отдельная роль «хранителя карты» не работает по той же причине, по которой не работает отложенное обновление спеки: хранитель узнаёт об изменениях последним.

Когда обновляется. Тем же изменением, что и код. Появление седьмого сервиса — это не «потом нарисуем», а часть работы по его заведению, наравне с конвейером и журналированием.

Что должно происходить автоматически. Часть карты выводится из артефактов и потому не устаревает: список сервисов — из реестра развёртывания, связи по событиям — из схем сообщений и подписок, синхронные вызовы — из описаний контрактов и настроек. Ручного описания стоит оставлять ровно столько, сколько нельзя вывести: границы контекстов, ограничения «чего сервис не делает», владение сущностями. Это и есть самая ценная часть, и она же самая недолговечная.

Как ловится устаревание. Простая проверка в сборке: сервис существует, а на карте его нет — падение. Событие публикуется, а ребра на карте нет — падение. Всё, что не проверяется машиной, проверяется раз в квартал на разборе: карту открывают и читают вслух по стрелкам, а несогласия превращаются в задачи.

Что делать, когда карта расходится с реальностью сильно. Не перерисовывать по памяти: собрать фактическое состояние из реестров и схем, сравнить с картой и разобрать разницу на три части — что изменилось осознанно (поправить карту), что появилось само (обсудить, нужно ли оно), чего нет в реальности (удалить или признать невыполненным решением). Вторая часть обычно самая интересная: именно там обнаруживаются интеграции, о которых никто не договаривался.

Дополнительно: при первом чтении можно пропустить

Глубже: Payment: идемпотентность, повторы и деньги в спецификациирасширенное

Из шести контекстов разобраны три, а Payment, на котором построены примеры про идемпотентность во всех статьях об агентах, остался одной строкой. Набросок его спеки показывает, чем контекст денег отличается от остальных.

Агрегаты. Payment (платёж покупателя за заказ: создан, авторизован, захвачен, отказан, возвращён) и Payout (выплата продавцу после эскроу: запланирована, отправлена, подтверждена, отклонена). Order ссылается на платёж по идентификатору и не знает, что внутри; Payment знает о заказе только идентификатор и сумму. Комиссия это value object с правилом расчёта, а не колонка.

Идемпотентность как правило контекста. Каждая команда, которая двигает деньги, принимает ключ идемпотентности от вызывающего: AuthorizePayment(orderId, amount, idempotencyKey). Обработчик первым действием транзакции вставляет ключ в таблицу payment_idempotency(key, actor, request_hash, status, response); повтор с тем же ключом и хешем возвращает сохранённый ответ, с другим хешем 422, в работе 409. Ключ живёт не меньше недели. Это записано в разделе «Роли и доступ» как общее правило контекста и повторяется в каждой карточке команды строкой «Идемпотентность: по ключу».

Повторы и провайдер. Платёжный провайдер отвечает не всегда: таймаут не означает отказ. Команда AuthorizePayment вызывает провайдера вне транзакции с базой, с ключом идемпотентности провайдера, равным нашему идентификатору платежа, и при таймауте переводит платёж в PENDING_PROVIDER, а не в отказ; отдельный процесс опрашивает статус застрявших с растущим интервалом и после суток отдаёт человеку. Уведомления от провайдера (вебхуки) идемпотентны по идентификатору события и переводят состояние по матрице переходов, а не по порядку прихода.

Деньги в спеке. Суммы в минорных единицах целым числом с валютой; правила BR-P01 «сумма захвата не больше авторизованной», BR-P02 «возврат не больше захваченного минус уже возвращённое», BR-P03 «выплата продавцу только после истечения окна споров»; каждое правило с кодом ошибки и тестом. Журнал операций по платежу только на добавление, с актором и причиной, потому что разбор спора начинается с него.

Контур. Отдельный сервис с узким доступом: свой сервисный аккаунт, свои секреты провайдера, ни одного прямого чтения таблиц из других сервисов; события PaymentCaptured, PaymentRefunded, PayoutSettled наружу через outbox. Так Payment проходит проверку по требованиям к платёжным данным без того, чтобы туда попадал весь маркетплейс, о чём статья про PCI DSS.

Полная спецификация Payment в кейсе будет отдельной статьёй; набросок выше это то, чем она отличается: ключ идемпотентности в каждой команде, провайдер вне транзакции с опросом застрявших, суммы целыми и журнал только на добавление.

Коротко

  • Начинать с событий, не с сущностей: Event Storming выявляет естественные группы — будущие контексты. Bounded Context — часть системы со своим языком: одно слово в разных контекстах значит разное.
  • Граница сервиса — это то, что он не делает, не только то, что делает. У каждой сущности ровно один владелец; остальные читают через API или события. Решение «монолит или микросервисы» принимается отдельно, на основе нагрузки, команды и требований.
  • Синхронные вызовы — когда нужен ответ сейчас; асинхронные события — когда блокировать не нужно. Saga нужна для операций, которые затрагивают несколько сервисов и должны откатываться при сбое. Transactional Outbox гарантирует, что событие не потеряется при падении сервиса.
  • Поведение при отказе соседа продумывается до детального проектирования, не после. Payment в спеке: агрегаты платежа и выплаты по идентификаторам, ключ идемпотентности в каждой команде с таблицей ответов, провайдер вне транзакции с опросом застрявших, суммы целыми, журнал только на добавление, изолированный контур с событиями через outbox.
  • Модульный монолит выигрывает в транзакциях, скорости первой версии, дешёвом сдвиге границ и эксплуатации; разделение выбрали из-за разной нагрузки, изоляции контура денег и независимого выпуска четырёх команд.
  • Справочники раздают по частоте изменения: редкие и терпимые к расхождению — копией по событию, редкие и строгие — версиями с датой действия, частые — фиксацией значения в момент операции.
  • Сагу оформления ведёт Order своим компонентом; компенсации идут в обратном порядке, резерв снимается и по сроку жизни, а непрошедшая компенсация повторяется идемпотентно и уходит оператору с историей шагов.
  • Частичный отказ важнее полного: у каждого вызова предельное время ожидания меньше нашего срока ответа, повтор только для идемпотентного, поздний ответ обязан быть обработан, а нарастание ошибок размыкает вызовы.
  • Уровни зрелости в одной системе разные: Order и Payment на третьем, Catalog со вторым и отдельной моделью чтения, Notification и Customer на втором, Backoffice на первом.
  • Карта живёт текстом и схемой-исходником в репозитории, обновляется тем же изменением, что и код, частью выводится из реестров и контрактов, а устаревание ловится проверкой в сборке и квартальным разбором.

Что почитать дальше