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

Когда система вырастает, её делят на несколько независимых частей — Bounded Context. Каждая часть отвечает за свой кусок предметной области: заказы, доставка, оплата. Но части всё равно должны как-то взаимодействовать.

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

У Эванса на карте контекстов девять отношений. Разберём семь самых ходовых: Published Language почти всегда идёт в паре с Open Host Service, а девятое отношение — Big Ball of Mud, то есть отсутствие всяких границ, — разобрано в стратегических паттернах.

Начнём с самого частого случая: чужая система отдаёт данные в своём формате, и весь вопрос в том, где этот формат остановится.

поставщик меняет формат — вопрос в том, сколько мест это заденет платёжная система orderStatus state "APPROVED" "PAID_OK" чужая модель поставщик сменил поле как есть наш домен заказов оплата заказа "APPROVED" отчёт по дню "APPROVED" письмо клиенту "APPROVED" "PAID_OK""PAID_OK""PAID_OK"правим в трёх местах слой перевода orderStatus → наш PAID stateправим в одном месте наш домен заказов оплата заказа PAID отчёт по дню PAID письмо клиенту PAID домен не тронут

Одно переименование на стороне поставщика. Сверху его слово попало прямо в домен и лежит в трёх местах — править придётся все три. Снизу чужой формат останавливается в слое перевода: меняется одна строка в адаптере, а домен продолжает говорить своим 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) на границах.

правка Money Orders пересобрать Payments пересобрать Shipping пересобрать Billing пересобрать

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

Общая библиотека уже есть: как из неё выходить

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

Шаг 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 (осторожно в микросервисах)
нужна связь чей контекст нет: Separate Ways Conformist чужая, модель устраивает ACL чужая, домен беречь OHS + PL своя, потребителей много 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 стоит тем, что цена выхода растёт с числом мест, где упомянут чужой тип: держите чужое в одном пакете, не сравнивайте чужие статусы в логике, ходите через одну точку — и пересматривайте решение по признакам, а не по ощущению.
  • Из существующей общей библиотеки выходят шагами: заморозить рост, версионировать с поддержкой предыдущей версии, продублировать просочившуюся модель по сервисам, на границах примитивы, в общем оставить два-три типа.
  • Синхронный вызов или событие решает вопрос «кому нужен результат»: вызывающему — вызов или своя копия данных, кому-то другому — событие о факте, а не команда о действии; ответить быстро и остальное событиями — самая частая рабочая схема.
  • Порядок доставки не равен порядку событий: ключ раздела по сущности, версия агрегата в событии, обработчик, терпимый к порядку, и время события в полезной нагрузке.
  • Защитный слой, в котором модель скопирована с чужой один в один, не защищает: настоящий перевод теряет лишние поля, складывает чужие статусы в свои и говорит словами вашего домена.

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