Когда система вырастает, её делят на несколько независимых частей — Bounded Context. Каждая часть отвечает за свой кусок предметной области: заказы, доставка, оплата. Но части всё равно должны как-то взаимодействовать.
Здесь и возникает главный вопрос: как одна часть системы разговаривает с другой? От ответа зависит, насколько они будут независимы, кто диктует правила изменений и насколько дорого обходится поддержка.
У Эванса на карте контекстов девять отношений. Разберём семь самых ходовых: Published Language почти всегда идёт в паре с Open Host Service, а девятое отношение — Big Ball of Mud, то есть отсутствие всяких границ, — разобрано в стратегических паттернах.
Начнём с самого частого случая: чужая система отдаёт данные в своём формате, и весь вопрос в том, где этот формат остановится.
Одно переименование на стороне поставщика. Сверху его слово попало прямо в домен и лежит в трёх местах — править придётся все три. Снизу чужой формат останавливается в слое перевода: меняется одна строка в адаптере, а домен продолжает говорить своим PAID.
Anti-Corruption Layer — защитный слой-переводчик
Представьте: ваш код заказов должен получать данные из старой платёжной системы. У неё своя терминология, свои поля, свой формат. Если тянуть её понятия прямо в свой код — в итоге ваш домен заказов начнёт говорить на языке платёжной системы. Поменяется платёжная — придётся переписывать домен.
Anti-Corruption Layer (ACL) — это слой-переводчик. Он принимает «чужой» формат и переводит его в понятия вашего домена. Снаружи — чужой мир, внутри — ваши типы.
// Порт — ваш домен описывает, что ему нужно
public interface PaymentGateway {
PaymentOrder getPaymentStatus(PaymentOrderId id);
}
// ACL-адаптер — переводит чужой ответ в ваш тип
@Component
public class SberPaymentAdapter implements PaymentGateway {
private final SberClient client;
public SberPaymentAdapter(SberClient client) {
this.client = client;
}
@Override
public PaymentOrder getPaymentStatus(PaymentOrderId id) {
SberPaymentResponse response = client.getOrderStatus(id.value());
return new PaymentOrder(
new PaymentOrderId(response.getOrderId()),
Money.ofKopecks(response.getAmount()),
mapStatus(response.getOrderStatus())
);
}
// Вот он, весь перевод: чужие слова остаются здесь
private PaymentStatus mapStatus(String sberStatus) {
return switch (sberStatus) {
case "APPROVED", "DEPOSITED" -> PaymentStatus.PAID;
case "DECLINED" -> PaymentStatus.DECLINED;
case "REFUNDED" -> PaymentStatus.REFUNDED;
default -> PaymentStatus.PENDING;
};
}
}
Смена платёжного провайдера затрагивает только адаптер. Домен заказов ничего не знает о Сбере — он работает через интерфейс PaymentGateway.
ACL применяется везде, где граница между «нашей» и «чужой» моделью существенна: внешние API, сторонние сервисы, соседние команды. В микросервисной архитектуре ACL — стандарт для каждой внешней интеграции.
Open Host Service — публичный API с устойчивым форматом
Обратная ситуация: не вы потребляете чужое, а другие потребляют ваше. Если вы просто «открываете» внутренние классы наружу — любое изменение модели ломает клиентов.
Open Host Service (OHS) — это когда контекст публикует стабильный документированный API. Внутренняя модель может меняться, а API остаётся предсказуемым.
Published Language — конкретный формат этого API: стабильные DTO, OpenAPI-схема, версионированный контракт.
// Стабильный контракт — Published Language
public record OrderJson(
UUID orderId,
String status,
BigDecimal totalAmount,
String currency,
OffsetDateTime createdAt
) {}
// Контроллер публикует API, не доменную модель
@RestController
@RequestMapping("/api/v1/orders")
public class OrderApiController {
private final Dispatcher dispatcher;
private final OrderJsonMapper mapper;
public OrderApiController(Dispatcher dispatcher, OrderJsonMapper mapper) {
this.dispatcher = dispatcher;
this.mapper = mapper;
}
@GetMapping("/{id}")
public OrderJson getOrder(@PathVariable UUID id) {
OrderDto dto = dispatcher.dispatch(new GetOrderQuery(id));
return mapper.toJson(dto); // маппер изолирует домен от контракта
}
}
Клиенты зависят от OrderJson, а не от внутреннего класса Order. Добавили поле в Order — OrderJson не меняется, клиенты не ломаются.
В микросервисах это оформляется как OpenAPI-спецификация с версией в URL (/api/v1/, /api/v2/). Контракт проверяется контракт-тестами в CI.
Customer–Supplier — поставщик и потребитель
Один контекст поставляет данные или функциональность, другой потребляет. Поставщик (Supplier) решает, как и когда менять контракт. Потребитель (Customer) влияет на приоритеты, но не диктует условия.
Классический пример: сервис каталога поставляет данные о продуктах, сервис заказов их потребляет.
// Supplier объявляет интерфейс
public interface ProductQueries {
Optional<Product> findById(ProductId id);
}
// Customer получает его через внедрение зависимости
public class CreateOrderHandler {
private final ProductQueries products;
// ...
}
В микросервисах поставщик публикует REST или Kafka API, поддерживает SLA, ведёт журнал изменений. Потребитель — формальный клиент. Breaking changes поставщик согласует заранее.
Ключевое правило: изменение контракта — ответственность поставщика. Потребитель следит за журналом изменений, контракт-тесты проверяют совместимость автоматически.
Conformist — принятие чужой модели как есть
Иногда самый простой выход — использовать модель другой системы напрямую, без перевода. Это паттерн Conformist: мы принимаем чужой формат данных и не создаём свою модель поверх него.
Когда это оправдано: внешний API стабилен, меняется редко, его модель вас полностью устраивает.
// Используем модель ЦБ РФ напрямую — меняется раз в годы
public record CbrCurrencyRate(String code, BigDecimal rate, LocalDate date) {}
@Component
class CurrencyService {
private final CbrClient cbrClient;
public CbrCurrencyRate getRate(String code) {
return cbrClient.getRate(code); // никакого маппинга
}
}
Conformist — это осознанный выбор, не лень. Если чужая модель начала меняться, в ваш домен стали проникать чужие термины, или вы хотите поддерживать несколько версий API — пора строить ACL.
Внутри системы (между своими модулями или сервисами) Conformist почти всегда плохая идея: ACL внутри одного процесса почти бесплатный, зато даёт изоляцию.
Чем платят за Conformist и почему выход дорожает
Пример с курсом валют безобиден: три поля, меняется раз в годы. Вся цена этого паттерна становится видна на другом масштабе — когда чужая модель насчитывает восемьдесят полей, двенадцать статусов и своё представление о том, что такое клиент.
Что происходит со временем. Чужой тип попадает в подпись метода одного сервиса. Потом — в возвращаемое значение другого. Потом чужой статус начинает проверяться в условиях бизнес-логики (if (external.status().equals("PENDING_APPROVAL"))). Потом чужое имя поля уезжает в вашу базу, потому что «зачем переименовывать». Через год чужая модель — часть вашего домена, и её термины произносят на планировании.
Отсюда цена, и она нелинейная. Пока чужой тип живёт в трёх местах, переход к защитному слою — работа на день: один переводчик на границе, три правки. Когда он в трёхсот местах, это работа на месяцы, потому что каждая правка меняет поведение: где-то сравнение статусов, где-то проверка на пустое значение, где-то сортировка по чужому полю. Практическое правило: цена выхода растёт примерно как число мест, где чужой тип упомянут, а это число растёт само, без чьего-либо решения.
Что ещё платится, кроме работы. Чужое изменение прилетает без предупреждения (у вас нет рычага влиять на поставщика), и оно приезжает сразу в бизнес-логику. Чужая модель диктует ваш язык: там, где у вас «оплаченный заказ», в коде будет их «транзакция в состоянии подтверждения», и единый язык рассыпается. И тесты: без переводчика невозможно подставить простой объект — приходится собирать чужую структуру целиком в каждом тесте.
Как сделать Conformist безопасным, не строя полный переводчик. Компромисс, который стоит копейки и сохраняет обратимость:
- Чужой тип не выходит за пределы одного пакета. Внутри — как угодно, наружу — только свои типы. Это даёт то же, что защитный слой, но без модели-дублёра: переводчик появится позже в том же месте.
- Чужие значения не сравниваются в бизнес-логике. Статус чужой системы один раз превращается в ваш признак (
isApproved), и дальше код смотрит на свой. - Одна точка вызова. Все обращения к чужому клиенту идут через один класс, а не из десяти мест. Тогда «построить защитный слой» однажды означает изменить один класс.
Когда пора уходить, по признакам, а не по ощущению: чужой тип упоминается больше чем в десяти местах; чужое изменение за последний год ломало вас дважды; в разговоре о домене звучат чужие термины; появился второй поставщик той же услуги (тогда выбора нет — слой нужен); вы начали писать код в обход чужой модели (if (weird == null) weird = "";) — это уже перевод, только спрятанный.
И честно: тянуть год иногда правильно. Если чужая система стабильна, а ваш контекст поддерживающий и через год его планируют заменить, платить за слой незачем. Плохо не то, что выбрали Conformist, а то, что выбрали его один раз и больше не пересматривали.
Shared Kernel — общая часть модели
Некоторые типы фундаментальны и нужны нескольким контекстам: идентификаторы, денежные суммы, базовые перечисления. Дублировать их в каждом модуле неудобно.
Shared Kernel — небольшая общая часть модели, которой совместно владеют контексты.
shared-kernel/
└── src/main/…/shared/
├── ids/UserId
├── ids/OrderId
└── value/Money
Правило: в Shared Kernel только фундаментальные типы без бизнес-логики. Никаких агрегатов, никаких правил домена. Изменение типа в Shared Kernel требует согласования всех команд, которые его используют.
В монолите это просто общий модуль. В микросервисах — осторожно: общая библиотека связывает деплои всех сервисов. Если поменяли Money — надо пересобрать и выкатить все сервисы одновременно. Это называют «распределённым монолитом» и считают проблемой. Альтернатива: дублировать тип в каждом сервисе и передавать примитивы (UUID, String) на границах.
Одна строка в общей библиотеке расходится на все четыре сервиса сразу: смотрите на то, что выкат перестал быть делом одной команды.
Общая библиотека уже есть: как из неё выходить
Совет «дублировать типы» хорош для нового проекта. Реальность обычно другая: общая библиотека существует, в ней тридцать классов вместо трёх, от неё зависят восемь сервисов, и выкатить всё одновременно нельзя. Выход есть, и он делается по шагам, без остановки работы.
Шаг 0. Понять, что там лежит. Классы делятся на три группы, и обращаться с ними надо по-разному:
- Настоящие фундаментальные типы (
Money,OrderId,Currency) — их можно оставить, но заморозить (см. ниже). - Модель домена, которая просочилась (
Order,Customer, статусы бизнес-процесса) — то, из-за чего библиотека и стала проблемой. Это дублируется по сервисам. - Технические утилиты (клиент HTTP, конфигурация, обёртки над журналом) — это не Shared Kernel вовсе, а внутренняя библиотека, и к ней правила Shared Kernel не относятся: её можно версионировать как обычную зависимость.
Шаг 1. Заморозить. Первое и самое дешёвое действие: договориться, что новых классов в библиотеку не добавляют, а изменения существующих требуют обсуждения. Это ничего не стоит и останавливает рост. Механически поддерживается проверкой в сборке или списком владельцев файла — не «договорились на словах».
Шаг 2. Версионировать по-настоящему, с поддержкой предыдущей версии. Библиотека, которую надо выкатывать одновременно, — это библиотека без версий. Правило, которое возвращает независимость выката: каждая версия совместима с предыдущей (только добавлять, ничего не удалять и не менять смысл), а сервисы обновляются в своём темпе. Ломающее изменение оформляется как новая мажорная версия, при которой старая продолжает поддерживаться, пока на неё есть потребители. То есть тот же режим «поддерживаем N-1», что у публичного контракта.
Шаг 3. Продублировать то, что не должно быть общим. Модель домена уезжает по сервисам: каждый заводит свой класс, свои статусы, своё понимание. Делается это без флага дня перехода: новый класс появляется рядом, код переводится на него по частям, старый импорт удаляется последним. Момент, когда становится не страшно: у каждого сервиса свой Order, и они разные — именно это и требовалось.
Шаг 4. На границах — примитивы или свои типы. Сервисы обмениваются не общими классами, а данными: строки, числа, свои структуры запроса и ответа. Здесь и живёт основная работа перехода, и она же даёт основную пользу: появляется явный контракт вместо «у нас же общий класс».
Шаг 5. Что остаётся общим — по-настоящему мало. После всего обычно остаётся два-три типа: денежная сумма, пара идентификаторов, может быть валюта. Это и есть Shared Kernel, каким он должен был быть; такую библиотеку можно менять раз в год и без боли.
Чего не делать: объявлять «переходим с общей библиотеки» единым днём (не получится, сервисы выкатываются в разном темпе); удалять классы из библиотеки, пока на них кто-то ссылается (сборка у соседей ляжет); и оставлять «пока общим» то, что уже начало мешать, — обычно это мешает ровно до следующего инцидента с одновременным выкатом.
Признак, что вы в ловушке, и её надо разбирать первым делом: выкат одного сервиса требует одновременного выката других. Это уже не Shared Kernel, а распределённый монолит, и цена такого состояния растёт с числом сервисов.
Partnership — совместная разработка
Два контекста развиваются вместе: обе команды совместно согласовывают изменения контракта, релизы координируются.
Это уместно, когда два модуля разрабатывает одна команда или две команды очень тесно сотрудничают. Нет формального «поставщика» — обе стороны равноправны.
// Оба модуля согласовали добавление нового поля
public record CreateOrderRequest(
UUID customerId,
List<OrderItemRequest> items,
UUID warehouseId // ← добавлено совместно Orders + Inventory
) {}
Риск: если команды разойдутся или появятся новые менеджеры — совместная разработка превращается в неконтролируемую связность. Сигнал к смене паттерна: ввести версионирование и перейти к Customer–Supplier.
Separate Ways — независимые пути
Иногда два контекста просто не должны знать друг о друге. Они решают разные задачи, даже если используют похожие данные.
Пример: сервис уведомлений и сервис аналитики оба работают с «пользователями», но для совершенно разных целей. Никакой интеграции между ними нет.
Notification Service — своя таблица пользователей (email, телефон, настройки)
Analytics Service — своя таблица посетителей (сегмент, последний визит)
Дублирование данных? Да. Но зато полная независимость: релизы, инциденты, деплои — всё отдельно. Иногда это правильный выбор.
Domain Events — как контексты сообщают о том, что произошло
Domain Events — не отдельный паттерн выбора связности, а механизм коммуникации. Контекст публикует событие о том, что произошло («Заказ подтверждён»), другой контекст реагирует.
В монолите события передаются внутри процесса через event bus. Spring предоставляет ApplicationEventPublisher:
@Service
class ConfirmOrderHandler {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
@Transactional
public void handle(ConfirmOrderCommand cmd) {
Order order = orders.findById(cmd.orderId()).orElseThrow(OrderNotFound::new);
order.confirm();
orders.save(order);
events.publishEvent(new OrderConfirmed(order.getId(), order.getTotal()));
}
}
@Component
class ShippingListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(OrderConfirmed e) {
// Выполняется только после успешного коммита заказа
}
}
Что здесь действительно гарантировано: если транзакция откатилась — слушатель не вызовется. Реакция происходит только при успешном сохранении заказа.
А вот чего гарантии нет, и об этом лучше узнать заранее. Если слушатель упадёт уже после коммита, заказ останется подтверждённым, а реакции не будет: Spring ловит такое исключение, пишет в лог и идёт дальше — наружу оно не выходит и ничего не отменяет. То есть AFTER_COMMIT теряет реакцию ровно так же, как теряется событие при падении до отправки в брокер, просто тише. Если реакция обязана случиться, её делают повторяемой: задание пишут в базу той же транзакцией, а выполняет его отдельный процесс с повторами.
Вторая мина — событие без транзакции. У @TransactionalEventListener есть флаг fallbackExecution, и по умолчанию он false: если событие опубликовали там, где транзакции нет, слушатель не вызовется вообще и в логе ничего не появится. В тестах и в вызовах из фоновых задач на этом теряют полдня.
В микросервисах события передаются через брокер сообщений (Kafka, RabbitMQ). Здесь возникает проблема: что если сервис сохранил заказ в базу, но упал до публикации события в Kafka? Событие потеряно.
Решение — Transactional Outbox: событие записывается в ту же базу данных в той же транзакции. Отдельный процесс (relay) читает события из базы и публикует в Kafka.
@Transactional
public void handle(ConfirmOrderCommand cmd) {
Order order = orders.findById(cmd.orderId()).orElseThrow(OrderNotFound::new);
order.confirm();
orders.save(order);
// Outbox — в той же транзакции, что и сохранение заказа
outbox.save(OutboxEvent.of(
"order.confirmed.v1",
order.getId().toString(),
new OrderConfirmedV1(order.getId(), order.getTotal())
));
}
// Отдельный процесс читает outbox и публикует в Kafka
@Scheduled(fixedDelay = 1000)
@Transactional
public void publish() {
// SELECT ... FOR UPDATE SKIP LOCKED: соседний экземпляр возьмёт другие строки
outbox.lockPending(100).forEach(e -> {
kafka.send("order-events", e.payload()).join(); // ждём подтверждения брокера
outbox.markPublished(e.id());
});
}
Две детали здесь важнее, чем выглядят.
join() — потому что send только кладёт сообщение в очередь отправки и сразу возвращает управление. Пометить событие доставленным, не дождавшись ответа брокера, значит потерять его при первом же обрыве сети — ровно ту потерю, ради которой outbox и заводили.
lockPending вместо простого «прочитать неотправленные» — потому что экземпляров сервиса в проде минимум два. Без блокировки оба вычитают одни и те же строки и отправят каждое событие дважды. Запрос с FOR UPDATE SKIP LOCKED отдаёт каждой копии свой кусок и не заставляет её ждать чужой.
Дубликаты всё равно возможны, но приходят они не оттуда, где их обычно ищут. С версии 3.0 продюсер Kafka по умолчанию идемпотентный (enable.idempotence=true), так что повторы на участке «отправитель → брокер» сняты самим брокером. Остаются два своих источника: relay отправил событие, но упал до markPublished — и отправит снова; потребитель обработал событие, но не успел зафиксировать смещение — и получит его ещё раз.
Отсюда правило, которое проще принять сразу: событие может прийти дважды, поэтому потребитель обязан быть идемпотентным. Самый дешёвый способ — держать таблицу обработанных идентификаторов событий и молча пропускать знакомые.
Как выбрать паттерн
Простое дерево решений:
- Нет интеграции → Separate Ways
- Внешняя система, их модель устраивает → Conformist
- Внешняя система, нужна защита домена → Anti-Corruption Layer
- Один поставляет, много потребляют → Open Host Service + Published Language
- Один поставляет, один потребляет → Customer–Supplier
- Одна команда, два модуля → Partnership
- Общие базовые типы → Shared Kernel (осторожно в микросервисах)
Вопросы заданы по порядку: сначала нужна ли связь вообще, потом чей контекст по ту сторону, и только подписи на стрелках доводят до конкретного паттерна.
Синхронный вызов или событие
Дерево решений выше отвечает на вопрос «какой тип связи между контекстами», и оставляет открытым другой, который встаёт в каждой задаче: позвать соседа сейчас или послать событие. «События разрывают цепочку» не помогает, когда ответ нужен здесь и сейчас. Признак для выбора один, и он простой.
Кому нужен результат — вопрос номер один. Если результат нужен вызывающему, чтобы продолжить свою работу и ответить пользователю, — это синхронный вызов, и никакое событие его не заменит. «Проверь, есть ли товар на складе, я решаю, можно ли оформить заказ» — вызов. Если результат нужен кому-то другому, чтобы сделать свою часть работы, — это событие. «Заказ оплачен» — складу надо зарезервировать, бухгалтерии провести, покупателю написать; вызывающему от них ничего не нужно.
Из этого правила следуют все остальные различия:
| Синхронный вызов | Событие | |
|---|---|---|
| Кто знает о ком | вызывающий знает получателя | издатель не знает подписчиков |
| Что при отказе получателя | падает вызывающий | событие ждёт в очереди |
| Согласованность | сразу | отложенная, с окном рассогласования |
| Добавить второго потребителя | правка кода вызывающего | ничего не менять |
| Отладка | стек и трасса целиком | связывание по идентификатору |
| Ответ | есть | нет |
Три частых случая и правильный ответ.
«Мне нужны данные для ответа пользователю». Синхронный вызов — или, если сосед медленный и часто нужен, своя копия данных, обновляемая событиями. Второй вариант — то, чем разрывают неприятную зависимость: контекст доставки держит у себя нужные поля заказа и не зависит от доступности оформления. Цена — данные чуть устаревшие, и это надо принять осознанно.
«Мне нужно, чтобы сосед что-то сделал». Событие о факте, а не команда о действии. Разница не косметическая: «Заказ оплачен» оставляет решение за складом (резервировать? частично? отказать?), а «Зарезервируй товар» делает издателя ответственным за чужую логику и превращает событие в синхронный вызов через брокер. Правило: событие говорит о прошлом, команда — о будущем; между контекстами публикуют события.
«Мне нужно подтверждение, что сосед сделал». Самый спорный случай. Если подтверждение нужно пользователю прямо сейчас — это синхронный вызов (или честный ответ «принято, статус узнаете позже»). Если можно подождать — это событие туда и событие обратно, то есть процесс из нескольких шагов, у которого должен быть хозяин: разбор — в разделе «Глубже» про сагу ниже.
Смешанный приём, который применяют чаще всего. Ответить пользователю быстро по своим данным, а остальное сделать событиями: «заказ принят» (синхронно, по своим данным, за 20 миллисекунд) и дальше события в склад, оплату, уведомления. Пользователь получает ответ сразу, контексты остаются развязанными, и это же объясняет, почему в такой схеме ответ часто звучит как «принято», а не «сделано».
Чего не делают. Не превращают синхронную цепочку в событийную ради моды: три вызова, каждый из которых нужен для ответа, событиями не делаются, а с ними уезжает и наблюдаемость, и простота. И не посылают событие, когда на самом деле нужен ответ: получится «событие туда, событие обратно, ожидание в коде» — то есть синхронный вызов, собранный вручную и хуже.
И про порядок: события приходят не так, как случились
Отдельная вещь, которая ломает событийную интеграцию и которую не видно на схемах: порядок доставки не равен порядку событий. «Товар зарезервирован» может прийти раньше «заказ оплачен» — и обработчик, не готовый к этому, создаст резерв под неоплаченный заказ или упадёт на отсутствующей записи.
Откуда это берётся: события одного заказа ушли в разные разделы брокера (а порядок гарантирован только внутри раздела); одно событие было повторено после сбоя и приехало позже; два издателя опубликовали независимо; потребитель обрабатывает в несколько потоков.
Что с этим делают, по возрастанию цены:
- Ключ раздела — идентификатор сущности. Все события одного заказа идут по ключу
orderId, значит, попадают в один раздел и обрабатываются по порядку одним потребителем. Это решение на стороне издателя и почти всегда первое, что надо сделать. Цена: потолок параллелизма равен числу разделов, а перекос по ключу делает один раздел горячим. - Версия агрегата в событии. Каждое событие несёт номер версии сущности, и потребитель видит, что пришло событие версии 5, а он обработал 3: значит, четвёртое потерялось или задержалось — и он откладывает обработку или запрашивает состояние. Это защита от порядка на стороне получателя, и она нужна там, где ключ раздела не спасает (два издателя).
- Терпимость к порядку в самом обработчике. Самый надёжный подход: сделать обработку такой, чтобы порядок не имел значения. «Заказ оплачен» при отсутствующем заказе — не ошибка, а причина отложить событие; повторное «оплачен» — не действие, а подтверждение. Это та же идемпотентность, разобранная ниже, плюс умение ждать.
- Время события, а не время получения. В событии несут время, когда факт случился, и получатель принимает решения по нему. Тогда «пришло позже» перестаёт значить «случилось позже» — и отчёты не расходятся.
Практический минимум для событийной интеграции: ключ раздела по сущности, время события в полезной нагрузке, обработчик, который не падает от неожиданного порядка. Всё остальное — по мере надобности.
Частые ошибки
ACL «протекает». ACL принимает чужой тип и возвращает его наружу — защита не работает. Всегда маппируйте до границы ACL, наружу — только свои типы.
Shared Kernel разросся. В «общий» модуль попала бизнес-логика, агрегаты, правила. Теперь все зависят от всего. Shared Kernel — только фундаментальные примитивы.
Temporal coupling. Синхронная цепочка: сервис A вызывает B, B вызывает C. Упал C — сломалось всё. Domain Events + асинхронная обработка разрывают цепочку.
God Context. Один контекст знает про всех: хранит клиентов, заказы, доставку, оплату. Нет смысла в Bounded Context — всё в одном месте. Делите по ответственности.
Защитный слой, который ничего не защищает. Самая частая ошибка темы и самая обидная: слой построен, переводчик написан, а модель домена скопирована с чужой один в один. Те же восемьдесят полей, те же имена, те же значения статусов — только в своих классах. Работы сделано много, изоляции нет: чужое изменение всё равно проходит насквозь, потому что ваша модель повторяет чужую.
Признаки, по которым это узнаётся мгновенно:
- Поля совпадают один к одному, включая те, которые вашему домену не нужны (
partnerSystemCode,legacyFlag2,reserved). - Значения статусов чужие: у вас в перечислении
PENDING_APPROVAL,AWAITING_SETTLEMENT— слова, которых нет в вашем языке домена. - Переводчик выглядит как список присваиваний один к одному, без единого решения: ни отбрасывания лишнего, ни объединения, ни вычисления.
- При изменении чужого контракта приходится менять свою модель — то есть ровно то, от чего слой должен был спасти.
Как выглядит настоящий перевод: он теряет информацию и принимает решения. Из восьмидесяти чужих полей берутся семь нужных; четыре чужих статуса складываются в два ваших (ОПЛАЧЕН и НЕ ОПЛАЧЕН, потому что остальные различия для вашего домена не значат ничего); чужие коды ошибок превращаются в два ваших случая («попробовать позже» и «отказано»); чужое «сумма в копейках строкой» становится вашей денежной суммой. Признак того, что перевод честный: ваша модель короче чужой и говорит вашими словами.
И проверка, которую можно сделать за минуту: откройте свой класс и спросите, понял бы его человек из бизнеса. Если он видит settlementState и partnerRef, слой не переводит, а переносит.
Глубже: сага и process manager: довести процесс через три агрегатарасширенное
Доменные события выше разрывают цепочку вызовов: заказ подтверждён, склад резервирует по событию, оплата списывает по событию. Разрыв решает связанность и оставляет новый вопрос: кто доводит процесс до конца, когда склад зарезервировал, а оплата отказала. Резерв висит, заказ в подвешенном состоянии, и никто за это не отвечает.
Сага это последовательность локальных шагов в разных агрегатах или сервисах, где у каждого шага есть компенсация: резерв снимается, списание возвращается, заказ отменяется. Общей транзакции нет; вместо отката всего целиком выполняются компенсации в обратном порядке до того шага, который не удался. Компенсация это не откат: деньги уже списаны, и «вернуть» это новая операция со своей возможностью отказа, поэтому компенсации ставят в очередь и повторяют до успеха, а после предела отдают человеку.
Два способа вести сагу. Хореография: каждый участник реагирует на события предыдущего и публикует своё; координатора нет, связи в коде участников. Просто для трёх шагов и непрозрачно для семи: чтобы понять, где застрял заказ, нужно собрать события из трёх сервисов, а логика «что делать при отказе на шаге четыре» размазана по всем. Оркестрация: отдельный объект, process manager, держит состояние процесса (какой шаг выполнен, какой ждёт ответа), отправляет команды участникам и по их событиям решает, что дальше. Это конечный автомат с таблицей order_saga(id, step, status, updated_at) и обработчиком событий, о чём статьи про автоматы и про BPM-движки как оркестраторы.
Что обязан уметь process manager. Хранить состояние в базе, а не в памяти: перезапуск сервиса между шагами не должен потерять сагу. Быть идемпотентным по событиям: одно событие придёт дважды, и второй раз не должен запустить шаг заново. Иметь таймауты: склад не ответил за минуту, значит шаг провален, а не ждём вечно. И иметь терминальные состояния с человеком: сага, которую не удалось ни завершить, ни компенсировать, попадает в список на ручной разбор, а не крутится.
Когда сага не нужна. Если три «агрегата» всегда меняются вместе и никогда порознь, это один агрегат с неверной границей, о чём раздел про границу в статье о тактических паттернах. Сага это цена за границы, которые нужны, а не способ склеить то, что не следовало резать.
Глубже: версии событий: v1 и v2 в одном топикерасширенное
В коде выше событие называется order.confirmed.v1 и класс OrderConfirmedV1, и на этом статья останавливается. Через полгода в событии нужно новое поле, а через год другое значение старого, и вот как с этим живут.
Совместимые изменения без новой версии. Добавить необязательное поле, добавить новое значение перечисления, которое старые потребители игнорируют, расширить диапазон: потребители, написанные как терпимые читатели (незнакомые поля пропускают), продолжают работать, и версия остаётся первой. Это большинство изменений, и правило «только добавлять» покрывает годы.
Несовместимое изменение это новая версия. Убрать или переименовать поле, изменить тип или смысл (сумма в копейках вместо рублей), изменить ключ: это order.confirmed.v2 с новым классом. Обе версии живут одновременно: издатель на время перехода публикует обе (или одну вторую, а для старых потребителей стоит преобразователь, который делает из v2 v1), потребители переезжают по одному в своём темпе, а издатель следит, кто ещё читает v1, по группам потребителей и по счётчику; когда читателей v1 не осталось месяц, её перестают публиковать, и класс удаляют. В одном топике или в двух, зависит от брокера: в Kafka чаще один топик с полем версии в заголовке и разными схемами, в RabbitMQ разные ключи маршрутизации.
Схема как контракт. Схема события (JSON Schema, Avro) лежит в репозитории издателя или в реестре схем, и сборка проверяет каждое изменение на совместимость с предыдущей версией, как это делает buf breaking для protobuf; ломающее изменение без новой версии не проходит сборку. Потребитель тестирует свой разборщик на примерах события каждой версии, которую обязан понимать.
Старые события в хранилище. Если события хранятся долго (журнал, event sourcing), старые версии никуда не денутся, и читатель обязан понимать все: для этого при чтении их поднимают до текущей версии цепочкой преобразователей (v1 в v2, v2 в v3), и это код, который живёт вечно; ещё одна причина держать число версий малым и менять смысл полей только новым событием.
Глубже: идемпотентный потребитель как механизмрасширенное
«Потребитель обязан быть идемпотентным» выше это требование; механизм короткий, и он один на весь сайт. Таблица обработанных событий (event_id, consumer) с первичным ключом в той же базе, что и бизнес-данные; вставка в неё первой, в той же транзакции, что и действие по событию; нарушение уникальности означает повтор, и его подтверждают без действия. Ключ это идентификатор события от издателя, а лучше естественный ключ (заказ плюс версия события), который защищает и от повторной публикации. Таблицу чистят по возрасту, а срок хранения длиннее самого долгого окна повторной доставки: срока хранения топика или очереди недоставленных, обычно от недели.
Два случая, которые ломают простую схему. Частичная обработка: событие требует трёх действий, второе упало, отметки нет, повтор выполнит первое второй раз; лечится тем, что все действия по событию идут в одной транзакции с отметкой, а внешние вызовы (письмо, HTTP) выносят в outbox той же транзакцией, а не делают напрямую. И порядок: «заказ оплачен» пришёл раньше «заказ создан» из-за разных партиций или повторов; потребитель проверяет версию агрегата в событии и откладывает или отбрасывает события не по порядку, а не применяет как пришли. Подробный разбор с кодом в статьях про паттерны через AMQP и про синхронизацию через события в CQRS.
Коротко
- ACL — слой-переводчик между чужой моделью и вашим доменом. Защищает домен от внешних изменений. Open Host Service + Published Language — стабильный публичный API, отделённый от внутренней модели.
- Customer–Supplier — один поставляет, другой потребляет. Поставщик управляет контрактом. Conformist — принятие чужой модели как есть. Оправдан для стабильных внешних API.
- Shared Kernel — общие базовые типы. В монолите удобен, в микросервисах опасен. Partnership — совместная разработка двух контекстов одной командой.
- Separate Ways — без интеграции. Полная независимость ценой дублирования. Domain Events — механизм оповещения: в монолите через event bus, в микросервисах через брокер + Transactional Outbox.
- Процесс через несколько агрегатов доводит сага: шаги с компенсациями, хореография для коротких цепочек, process manager с состоянием в базе, идемпотентностью, таймаутами и ручным разбором для остальных. События версионируют: совместимое добавление без версии, несовместимое это v2 рядом с v1 до последнего читателя, схема с проверкой в сборке, старые версии поднимают при чтении. Идемпотентный потребитель это таблица обработанных событий в той же транзакции, все действия в одной транзакции с outbox для внешнего, версия агрегата против событий не по порядку.
- Conformist стоит тем, что цена выхода растёт с числом мест, где упомянут чужой тип: держите чужое в одном пакете, не сравнивайте чужие статусы в логике, ходите через одну точку — и пересматривайте решение по признакам, а не по ощущению.
- Из существующей общей библиотеки выходят шагами: заморозить рост, версионировать с поддержкой предыдущей версии, продублировать просочившуюся модель по сервисам, на границах примитивы, в общем оставить два-три типа.
- Синхронный вызов или событие решает вопрос «кому нужен результат»: вызывающему — вызов или своя копия данных, кому-то другому — событие о факте, а не команда о действии; ответить быстро и остальное событиями — самая частая рабочая схема.
- Порядок доставки не равен порядку событий: ключ раздела по сущности, версия агрегата в событии, обработчик, терпимый к порядку, и время события в полезной нагрузке.
- Защитный слой, в котором модель скопирована с чужой один в один, не защищает: настоящий перевод теряет лишние поля, складывает чужие статусы в свои и говорит словами вашего домена.
Что почитать дальше
- Стратегические паттерны DDD — Bounded Context, Ubiquitous Language, Context Map.
- Тактические паттерны DDD — Entity, Value Object, Aggregate.
- Apache Kafka — основной транспорт для Domain Events в микросервисах.