Когда есть бизнес-бриф маркетплейса и нужно написать первую строчку кода — возникает соблазн сразу начать с деталей: какой агрегат у заказа, какие таблицы в базе, как устроена 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. Нарисовать карту с границами
Получаем шесть сервисов. Для каждого важно зафиксировать не только что он делает, но и что не делает — это важнее.
Ограничения каждого сервиса:
- Catalog не считает деньги. Цена в карточке — справочная, реальная цена фиксируется в Order при оформлении.
- Order не ведёт остатки. Остаток и резерв на нём живут в Catalog: Order просит зарезервировать и хранит у себя только ответ — идентификатор резерва и срок, до которого он действует.
- Payment не знает, что внутри заказа. Только платежи и планы выплат. Заказ для него — внешний идентификатор и сумма, а не корзина с товарами и адресом.
- Notification ничего не решает. Только отправляет по команде. Логика «когда уведомить» — в источнике события.
- Customer не хранит товарный профиль продавца. Customer — это identity и авторизация, отдельно от каталога.
- Backoffice не правит данные напрямую — действия идут через API соответствующего сервиса. Сам Backoffice ведёт журнал действий оператора: кто, когда и что запросил. Как изменились данные — пишет у себя их владелец.
Эти ограничения — самое ценное в карте.
Шаг 5. Определить, кто чем владеет
Каждая сущность имеет ровно одного владельца. Все остальные читают через интеграции, не лезут в чужую базу напрямую.
| Сущность | Владелец | Кто читает |
|---|---|---|
| Карточка товара | Catalog | Order (снимок при оформлении) |
| Остаток товара | Catalog | Order (через резерв) |
| Заказ | Order | Payment, Notification, Backoffice |
| Платёж / эскроу | Payment | Order (статус), Backoffice |
| Выплата продавцу | Payment | Backoffice |
| Профиль покупателя | Customer | Order, Notification |
| Профиль продавца | Customer | Catalog, 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. Продумать поведение при отказах
Для каждого синхронного взаимодействия нужен план: что происходит, если сосед недоступен.
| Падает | Кто страдает | Что делаем |
|---|---|---|
| Catalog | Web, Order | Web показывает закешированные карточки. Order отказывает в оформлении — нельзя резервировать без актуального остатка. |
| Order | Web | Web показывает «оформление недоступно». Платёж не создаётся. |
| Payment | Order | Saga ставит платёж в ожидание и повторяет, пока не истекли 15 минут резерва. Не уложились — резерв снимается, заказ помечается «истекший». |
| Customer | Все | JWT проверяется локально по публичному ключу, без обращения к Customer. Только новые входы в систему недоступны. |
| Notification | — | Никто не страдает. События остаются в Kafka, уведомления придут позже. |
| Backoffice | — | Аудит накапливается в очереди, оператор разбирает позже. |
Главный принцип: падение Notification и Backoffice не влияет на покупки. Падение Catalog или Order влияет, но с плавной деградацией — старый кеш, отказ только в новых заказах, а не во всём сайте.
Слева упавший сервис, дальше подмена и то, что видит покупатель: покупки останавливают только Catalog и Order.
Эти решения принимаются до детального проектирования каждого сервиса. Если спроектировать сервис, не зная его поведение при отказе соседа, придётся переделывать обработку ошибок и механизм повторных попыток.
Частичный отказ — то, что происходит на самом деле. Таблица выше делит мир на «работает» и «упал», а самый частый случай третий: сосед отвечает, но медленно, или отвечает не на все запросы, или подтверждает после того, как мы перестали ждать.
| Что происходит | Пример | Что делаем |
|---|---|---|
| Отвечает медленно | Catalog отдаёт карточку за 3 секунды вместо 50 мс | предельное время ожидания на каждый вызов, иначе очередь запросов растёт и падаем мы, а не сосед |
| Часть запросов падает | одна реплика Payment сломана, треть ответов — ошибка | повтор с задержкой; повторять можно только то, что идемпотентно |
| Отвечает после нашего срока | Payment подтвердил списание через 40 секунд, мы уже отказались | обязательно принять ответ позже: платёж опознаётся по ключу и либо применяется, либо возвращается |
| Отвечает неполно | вернул 80 позиций из 100 | либо считать это отказом, либо явно показать частичный результат; молча отдавать неполное нельзя |
| Ошибки нарастают | сосед деградирует, мы усиливаем нагрузку повторами | размыкатель: перестать звать на время, отвечать деградированно и проверять раз в интервал |
Три правила, которые из этого следуют и которые надо решить до проектирования сервисов:
- У каждого синхронного вызова есть предельное время ожидания, и оно меньше нашего срока ответа. Без этого медленный сосед превращается в наше падение: потоки заняты ожиданием.
- Повтор разрешён только идемпотентной операции. «Зарезервировать» с ключом — можно, «списать деньги» без ключа — нельзя. Это тот же ключ повтора, что в разделе про Payment.
- Ответ, пришедший после срока, должен быть обработан. Самая пропускаемая часть: мы отказались ждать, но сосед всё-таки сделал работу. Если такой ответ некуда деть, появляются деньги без заказа и резервы без владельца.
И признак, что план отказов проработан: для каждой стрелки на карте названы предельное время ожидания, поведение при его истечении и то, что происходит с поздним ответом. Если хотя бы для одной стрелки этого нет — там и будет первый ночной инцидент.
Шаг 8. Визуализировать итог
Финальный артефакт — диаграмма уровня контейнеров (C4 Container). Это то, что показывают новому разработчику и на техническом разборе с заказчиком.
Каждый узел на схеме — отдельный процесс, своя база данных, свой деплой. Что внутри каждого — описывается в его собственной спецификации.
Дальше
После карты сервисов каждый сервис получает детальную спецификацию на нужном уровне сложности:
- Catalog — CQRS: чтение через Elasticsearch, запись через PostgreSQL.
- Order — готовая спецификация: три агрегата, Saga, Outbox, управление доступом.
- Payment — строгая идемпотентность, изоляция контура денег.
- Notification — готовая спецификация: отправка без привязки к каналу.
- Customer — OAuth2 + JWT, без сложной доменной логики.
- Backoffice — CRUD плюс аудит.
Уровень детальности выбирается по сложности домена, а не ради единообразия. Catalog читают миллионы пользователей — нужен CQRS. Backoffice обрабатывает десять запросов в минуту — агрегаты там не нужны.
Раз вся методология построена на шкале зрелости, полезно приписать уровень каждому сервису — иначе «уровень детальности по сложности домена» остаётся общей фразой:
| Сервис | Уровень | Почему |
|---|---|---|
| Order | 3 | настоящие инварианты (резерв, суммы, переходы статусов), три агрегата, процесс с компенсациями |
| Payment | 3 | деньги, идемпотентность, строгие правила; ошибка стоит дороже всего |
| Catalog | 2 плюс разделение чтения и записи | правил немного, а нагрузка на чтение требует отдельной модели чтения |
| Notification | 2 | операции простые, но их много и нужны метрики, повторы и журнал доставки |
| Customer | 2 | вход и права; доменной логики почти нет, но операции должны быть явными |
| Backoffice | 1 | по сути 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 на первом.
- Карта живёт текстом и схемой-исходником в репозитории, обновляется тем же изменением, что и код, частью выводится из реестров и контрактов, а устаревание ловится проверкой в сборке и квартальным разбором.
Что почитать дальше
- Спецификация Order Service — как выглядит детальная спецификация сервиса на примере Order.
- Спецификация Notification Service — простой сервис с Уровнем 1 сложности.
- Бизнес-бриф маркетплейса — исходная точка, откуда начинался этот разбор.