Интеграционные паттерны DDD
Интеграционные паттерны DDD: ACL, Open Host Service, Published Language.
Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс.
Интеграционные паттерны DDD возникают когда система разделена на Bounded Context-ы. Главный вопрос — как контексты общаются. Выбор паттерна определяет: насколько они связаны, кто диктует правила, что произойдёт при изменении модели и сколько стоит поддержка.
Каждый паттерн выглядит по-разному в разных архитектурах. В слойном монолите всё в одном пакете и связано через Java-вызовы. В модульном монолите — это границы Gradle-модулей с публичными API. В микросервисах — это сетевая интеграция с собственными базами данных. Стоимость, риски и инструменты — разные.
В этой статье разбираем семь интеграционных паттернов в трёх архитектурах + Domain Events как универсальный механизм. Стратегические паттерны — в отдельной статье, тактические — здесь. Подробнее про выбор архитектуры — тут.
Три архитектуры в одной таблице
| Слойный монолит | Модульный монолит | Микросервисы | |
|---|---|---|---|
| Деплой | один артефакт | один артефакт | по сервису |
| Граница контекста | пакет | Gradle-модуль | отдельный процесс + БД |
| Вызов | Java-метод | Java-метод через интерфейс модуля | HTTP/gRPC/Kafka |
| Транзакция | одна @Transactional | одна @Transactional (внутри JVM) | Saga, eventual consistency |
| Контракт | сигнатура метода | module-api с интерфейсами | OpenAPI / AsyncAPI / Protobuf |
| Стоимость интеграции | минимальная | средняя | высокая |
| Риск связности | God Object | leaky API между модулями | distributed monolith |
Дальше каждый паттерн — в трёх воплощениях.
1. Anti-Corruption Layer (ACL)
Идея: защитный слой-переводчик между чужой моделью и нашим доменом. ACL не пропускает «чужие» термины, форматы и ограничения внутрь контекста.
В слойном монолите
В слойном монолите ACL применим только к внешним системам (платёжный шлюз, сторонний REST API, legacy-БД). Внутри монолита нет «чужих» моделей — все классы наши.
// adapter/payment/SberPaymentAdapter.java — внешний адаптер
@Component
public class SberPaymentAdapter implements PaymentGateway {
private final SberClient client;
@Override
public PaymentOrder getPaymentStatus(PaymentOrderId id) {
SberPaymentResponse response = client.getOrderStatus(id.value());
return toDomain(response);
}
private PaymentOrder toDomain(SberPaymentResponse r) {
return new PaymentOrder(
new PaymentOrderId(r.getOrderId()),
Money.ofKopecks(r.getAmount()),
mapStatus(r.getOrderStatus())
);
}
}
// core/domain/repository/PaymentGateway.java — порт
public interface PaymentGateway {
PaymentOrder getPaymentStatus(PaymentOrderId id);
}
Граница пакетов — в одной кодовой базе. Завтра сменится провайдер — заменяется только adapter/payment/.
В модульном монолите
ACL применим как между модулями, так и для внешних систем. Внутри-системные ACL особенно ценны: модули — отдельные Gradle-артефакты, и потребитель не должен знать внутреннюю модель поставщика.
orders-module/
├── orders-api/ # публичный контракт (DTO + порты)
│ └── src/main/java/.../api/
│ └── OrderQueryPort.java # порт для внешних модулей
├── orders-impl/
│ └── src/main/java/.../impl/
│ ├── OrderQueryAdapter.java # реализует OrderQueryPort
│ └── domain/Order.java # внутренняя модель — приватная
shipping-module/
├── shipping-impl/
│ └── src/main/java/.../impl/
│ ├── OrderInfoAcl.java # ACL: переводит OrderQueryPort.OrderInfo
│ │ # в shipping-domain Order
│ └── domain/Order.java # своя модель shipping
// orders-api/OrderQueryPort.java
public interface OrderQueryPort {
record OrderInfo(UUID id, BigDecimal total, String status, Address shipTo) {}
Optional<OrderInfo> findById(UUID id);
}
// shipping-impl/OrderInfoAcl.java — ACL внутри JVM
@Component
public class OrderInfoAcl {
private final OrderQueryPort ordersPort; // Spring inject из orders-impl
public ShippingOrder loadForShipping(UUID orderId) {
var info = ordersPort.findById(orderId)
.orElseThrow(() -> new OrderNotFound(orderId));
return new ShippingOrder(
new ShippingOrderId(info.id()),
info.shipTo(),
ShippingStatus.PENDING
);
}
}
Зависимость только на orders-api, не на orders-impl. Изменение приватной модели Orders не ломает Shipping.
В микросервисах
ACL — обязателен для каждой внешней интеграции (включая собственные сервисы соседних команд). Реализуется как HTTP/gRPC-клиент с маппингом.
@Component
@RequiredArgsConstructor
public class OrderInfoAcl {
private final OrderServiceClient client; // OpenFeign/Retrofit
private final RetryTemplate retry; // Resilience4j
public ShippingOrder loadForShipping(UUID orderId) {
OrderJson json = retry.execute(ctx -> client.getOrder(orderId));
return new ShippingOrder(
new ShippingOrderId(json.id()),
new Address(json.country(), json.city(), json.street()),
ShippingStatus.PENDING
);
}
}
К ACL добавляются resilience-паттерны (Retry, Circuit Breaker) — внешний вызов может упасть. Это уже не in-process.
Сравнение
| Слойный | Модульный | Микросервисы | |
|---|---|---|---|
| Применим к внешним системам | ✅ | ✅ | ✅ |
| Применим внутри системы | ❌ | ✅ (между модулями) | ✅ (между сервисами) |
| Стоимость | низкая | средняя | высокая (+resilience) |
| Что ломается при leak | один пакет | модуль | прод-инцидент |
2. Open Host Service + Published Language
Идея: контекст публикует стабильный документированный API, в котором внутренняя модель не «протекает». Published Language — формат данных API.
В слойном монолите
Внутри одного монолита OHS не нужен. Но для внешнего мира монолит публикует свой OHS — REST/gRPC API, отделённый от доменной модели через JSON-DTO.
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderApiController {
private final UseCaseDispatcher dispatcher;
private final OrderJsonMapper mapper;
@GetMapping("/{id}")
public OrderJson getOrder(@PathVariable UUID id) {
OrderDto dto = dispatcher.dispatch(new GetOrderQuery(id));
return mapper.toJson(dto);
}
}
// Published Language: стабильный контракт в OpenAPI
public record OrderJson(
UUID orderId,
String status,
BigDecimal totalAmount,
String currency,
OffsetDateTime createdAt
) {}
В модульном монолите
Каждый модуль публикует свой OHS как интерфейсы и DTO в <module>-api модуле. Это «внутренний OHS» — другие модули зависят от api-модуля, а не от impl.
orders-module/
├── orders-api/ # OHS = публичный контракт
│ └── api/
│ ├── OrderQueryPort.java
│ ├── CreateOrderPort.java
│ ├── dto/OrderDto.java # Published Language
│ └── dto/OrderStatus.java
└── orders-impl/ # внутренности скрыты
API модуля — стабильный. Внутренности (Aggregate, Repository) могут меняться без ломания соседей.
В микросервисах
OHS — отдельный сервис со своим REST/gRPC API. Published Language описан в OpenAPI / AsyncAPI / Protobuf, версионируется, контракт-тесты обязательны.
# orders-service/openapi.yaml — Published Language
paths:
/api/v1/orders/{id}:
get:
operationId: getOrder
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/OrderJson' }
components:
schemas:
OrderJson:
type: object
required: [orderId, status, totalAmount]
properties:
orderId: { type: string, format: uuid }
status: { type: string, enum: [DRAFT, PAID, SHIPPED] }
totalAmount: { type: number }
Потребители генерируют клиенты по этой спецификации (openapi-generator-maven-plugin). Версии — в URL (/api/v1/, /api/v2/). Подробнее — в REST API Style Guide: версионирование.
Сравнение
| Слойный | Модульный | Микросервисы | |
|---|---|---|---|
| Контракт | для внешних клиентов | <module>-api интерфейс | OpenAPI / Protobuf |
| Версионирование | URL v1/v2 | semver Gradle-модуля | URL + spec versioning |
| Совместимость | проверяется тестами | проверяется компилятором | контракт-тесты (Pact) |
3. Shared Kernel
Идея: маленькая общая часть модели, которой совместно владеют контексты — обычно идентификаторы, базовые Value Objects, общие типы.
В слойном монолите
Все модели общие — Shared Kernel «по умолчанию» весь код. Это не паттерн, а просто структура. Понятие появляется когда мы выделяем общий пакет:
src/main/java/com/example/
├── shared/
│ ├── Money.java
│ ├── Currency.java
│ └── ids/UserId.java
├── orders/
└── billing/
В модульном монолите
Shared Kernel — отдельный Gradle-модуль shared-kernel, на который зависят остальные.
shared-kernel/
├── build.gradle.kts # без зависимостей на доменные модули
└── src/main/java/.../shared/
├── ids/UserId.java
├── ids/OrderId.java
└── value/Money.java
orders-impl/
└── build.gradle.kts # implementation(project(":shared-kernel"))
Правило: в shared-kernel только фундаментальные типы. Никакой бизнес-логики, никаких агрегатов. Изменение типа в shared-kernel требует пересборки всех зависимых модулей — но в одном артефакте это бесплатно.
В микросервисах
Опасный паттерн. Общая Java-библиотека связывает деплои: чтобы выкатить новую версию shared-kernel, надо пересобрать и задеплоить все сервисы. Это распределённый монолит.
Альтернативы:
- Дублирование типов — каждый сервис описывает свой
OrderId,Money. Минус — дрейф моделей. - Schema Registry — общая схема в Avro / Protobuf, генерация классов в каждом сервисе на этапе сборки. Контракт — внешний артефакт, не Java-jar.
- Только примитивы и UUID — никаких общих типов вообще, передаём
String/UUIDв DTO.
// Микросервис A
public record OrderId(UUID value) { ... }
// Микросервис B — свой OrderId, идентичный по значению
public record OrderId(UUID value) { ... }
// На границах сервисов — UUID. Каждый сервис оборачивает в свой тип.
Сравнение
| Слойный | Модульный | Микросервисы | |
|---|---|---|---|
| Реализация | пакет shared/ | gradle-модуль | дублирование или schema |
| Стоимость изменения | минимальная | пересборка модулей | пересборка всех сервисов (плохо) |
| Риск | low | medium | high — distributed monolith |
4. Customer-Supplier
Идея: один контекст — supplier, другой — customer. Customer формально влияет на upstream, но именно supplier решает, когда и как менять контракт.
В слойном монолите
Customer-Supplier между двумя пакетами — почти теоретический паттерн. Customer вызывает publisher через интерфейс, оба компилируются вместе. Изменение интерфейса ловит компилятор сразу.
// supplier
package com.example.catalog;
public interface ProductQueries {
Optional<Product> findById(ProductId id);
}
// customer
package com.example.orders;
public class CreateOrderHandler {
private final ProductQueries products; // Spring inject
// ...
}
В модульном монолите
supplier-api модуль — поставщик. customer-impl зависит от supplier-api. Между релизами supplier-команда планирует изменения, customer-команда читает changelog.
// customer-impl/build.gradle.kts
dependencies {
implementation(project(":supplier-api")) // зависимость на API
// ВАЖНО: не зависим на supplier-impl
}
Изменение supplier-api требует согласования. Compile-time проверка ловит несовместимость.
В микросервисах
Customer-Supplier — один из самых распространённых паттернов в микросервисах. Supplier публикует REST/Kafka API, поддерживает SLA, владеет changelog. Customer — формальный потребитель.
Контрактные тесты — обязательны. Supplier публикует provider contract, customer — consumer contract, пайплайн проверяет совместимость.
Сравнение
| Слойный | Модульный | Микросервисы | |
|---|---|---|---|
| Где живёт контракт | Java-интерфейс | supplier-api модуль | OpenAPI / Pact |
| Как ловится breaking change | компилятор | компилятор | контракт-тесты в CI |
| SLA | n/a (один процесс) | n/a | формальный |
5. Conformist
Идея: downstream принимает модель upstream как есть, без адаптации. Подходит когда upstream стабилен и его модель нас устраивает.
В слойном монолите
Conformist — это использование чужого DTO без перевода в свою модель. Применимо для интеграции с внешними API, но внутри монолита почти не встречается.
@Component
public class EmployeeService {
private final ErpClient erp;
// Возвращаем ErpEmployeeDto без перевода — модель ERP нас устраивает
public List<ErpEmployeeDto> getActiveByDepartment(String dept) {
return erp.getEmployees(dept).stream()
.filter(ErpEmployeeDto::isActive)
.toList();
}
}
В модульном монолите
Conformist между модулями — антипаттерн. Модуль А импортирует DTO модуля B и использует напрямую. Это утечка модели B в A. Лучше всегда ACL внутри JVM — он почти бесплатный.
В микросервисах
Conformist допустим для стабильных внешних API (государственные сервисы, биржевые данные, внешние reference-системы). Для собственных сервисов — почти всегда лучше ACL.
// Используем модель ЦБ РФ напрямую — стабильный контракт,
// меняется раз в годы
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 → ACL
- появляются «костыли» при маппинге
- чужие термины просачиваются в доменный код
- upstream начал часто менять модель
- нужно поддерживать несколько версий чужого API
Сравнение
| Слойный | Модульный | Микросервисы | |
|---|---|---|---|
| Внутри системы | редко | антипаттерн | антипаттерн |
| Внешние стабильные API | ОК | ОК | ОК |
| Стоимость отказа | переписать клиент | переписать модуль | переписать сервис + миграция данных |
6. Partnership
Идея: два контекста развиваются совместно — оба влияют на интерфейс, обе команды координируют изменения.
В слойном монолите
Partnership — это просто соседние пакеты, разработанные одной командой. Отдельная стратегия не нужна.
В модульном монолите
Два модуля в одной команде, общий релиз-цикл. Изменение API одного модуля и потребляющего его модуля — в одном PR.
// orders-api/CreateOrderPort.java
public record CreateOrderRequest(
UUID customerId,
List<OrderItemRequest> items,
UUID warehouseId // ← добавлено совместно: Orders знает customer,
// Inventory знает что зарезервировать
) {}
В микросервисах
Partnership — две команды (или две части одной) разрабатывают сервисы совместно. Контракт меняется по обоюдному согласию. Релизы координируются.
Риск: если команды разойдутся (новые менеджеры, реорг) — Partnership деградирует в неконтролируемую связность. Сигнал перехода: ввести версионирование API и формализовать как Customer-Supplier.
Сравнение
| Слойный | Модульный | Микросервисы | |
|---|---|---|---|
| Когда уместно | always (одна кодовая база) | одна команда на оба модуля | тесная координация двух команд |
| Риск | n/a | срастание модулей | организационный долг |
7. Separate Ways
Идея: контексты не интегрируются. Каждый решает свою задачу независимо, даже если есть пересечение по данным.
В слойном монолите
Два независимых пакета без связей. Просто разная функциональность в одном приложении.
src/main/java/com/example/
├── notifications/ # своя модель пользователя
└── analytics/ # своя модель пользователя — независимая
В модульном монолите
Два модуля без зависимостей между ними. Возможно дублирование данных в БД (разные таблицы) или общая таблица с разными представлениями.
В микросервисах
Самый частый случай Separate Ways в большой системе. Два сервиса не знают друг о друге. Дублирование данных — норма.
Дублирование? Да. Но зато полная независимость деплоев, релизов, инцидентов.
Сравнение
| Слойный | Модульный | Микросервисы | |
|---|---|---|---|
| Стоимость дублирования | строки кода | таблицы в БД | целые подсистемы |
| Когда уместно | разные домены | независимые модули | независимые команды |
8. Domain Events — универсальный механизм
Domain Events — не отдельный паттерн интеграции, а механизм, на котором строятся остальные. Реализация существенно различается.
В слойном монолите
Spring ApplicationEventPublisher — события публикуются и потребляются в той же JVM, в той же транзакции. Синхронно или через @Async.
@Service
@RequiredArgsConstructor
class ConfirmOrderHandler {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
@Transactional
public void handle(ConfirmOrderCommand cmd) {
Order order = orders.findById(cmd.orderId());
order.confirm();
orders.save(order);
events.publishEvent(new OrderConfirmed(order.getId(), order.getTotal()));
}
}
@Component
class ShippingListener {
@EventListener
void on(OrderConfirmed e) {
// Выполняется в той же транзакции (по умолчанию)
// или после коммита через @TransactionalEventListener(AFTER_COMMIT)
}
}
Гарантии: транзакционные. Если коммит не прошёл — слушатели не вызовутся.
В модульном монолите
То же ApplicationEventPublisher — это внутренний event bus между модулями. Слушатели в одном модуле, publishers в другом.
Важно: события — публичная часть API. Их типы должны быть в <module>-api. Иначе слушатель не сможет подписаться без знания внутренней модели.
// orders-api/events/OrderConfirmed.java
public record OrderConfirmed(UUID orderId, BigDecimal totalAmount) {}
// shipping-impl/ShippingOnOrderConfirmed.java
@Component
class ShippingOnOrderConfirmed {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(OrderConfirmed e) {
// Создаём Shipment после коммита транзакции Orders
}
}
Граница: между модулями — только примитивные DTO событий, никаких ссылок на агрегаты.
В микросервисах
Domain Events — основа всей интеграции. Реализуется через брокер сообщений (Apache Kafka, RabbitMQ).
Обязателен паттерн Transactional Outbox — иначе при падении после коммита БД событие потеряется. См. распределённые паттерны.
@Service
@RequiredArgsConstructor
class ConfirmOrderHandler {
private final OrderRepository orders;
private final OutboxRepository outbox;
@Transactional
public void handle(ConfirmOrderCommand cmd) {
Order order = orders.findById(cmd.orderId());
order.confirm();
orders.save(order);
// Outbox — в той же транзакции
outbox.save(OutboxEvent.of(
"order.confirmed.v1",
order.getId().toString(),
new OrderConfirmedV1(order.getId(), order.getTotal())
));
}
}
// Отдельный процесс читает outbox и публикует в Kafka
@Component
class OutboxRelay {
@Scheduled(fixedDelay = 1000)
void publish() {
outbox.findPending().forEach(e -> {
kafka.send("order-events", e.payload());
outbox.markPublished(e.id());
});
}
}
// Подписчик в другом сервисе
@KafkaListener(topics = "order-events")
class ShippingConsumer {
@Transactional
void on(OrderConfirmedV1 event) {
// Идемпотентность ОБЯЗАТЕЛЬНА — at-least-once delivery
if (processed.contains(event.eventId())) return;
Shipment.create(event.orderId());
processed.mark(event.eventId());
}
}
Гарантии: at-least-once. Дубликаты возможны — потребитель должен быть идемпотентным. Порядок — только в рамках одной партиции (по ключу).
Сравнение
| Слойный | Модульный | Микросервисы | |
|---|---|---|---|
| Транспорт | ApplicationEventPublisher | ApplicationEventPublisher | Kafka / RabbitMQ |
| Гарантии | транзакционные | транзакционные | at-least-once + Outbox |
| Идемпотентность | не нужна | не нужна | обязательна |
| Где хранятся события | нигде (in-memory) | нигде | топик брокера + БД (event store) |
| Стоимость инфры | 0 | 0 | Kafka-кластер |
Дерево решений
Антипаттерны (общие для всех архитектур)
Leaky ACL. ACL возвращает чужой тип наружу. Защита не работает — внешняя модель просочилась в домен. Чем больше архитектура — тем дороже исправить.
Distributed monolith. Shared Kernel разросся до общей доменной модели. В микросервисах смерть от связности деплоев. В модульном монолите — постоянные конфликты merge.
Temporal coupling. Синхронная цепочка вызовов где сбой одного звена ломает всё. В микросервисах решается через Saga + Domain Events. В модульном монолите — через @TransactionalEventListener(AFTER_COMMIT).
God Context. Один контекст работает со всеми чужими моделями. В монолите — God Service. В микросервисах — сервис с десятком клиентов.
Шпаргалка по выбору
| Паттерн | Слойный | Модульный | Микросервисы |
|---|---|---|---|
| ACL | для внешних API | между модулями + внешние | для каждой интеграции |
| OHS + PL | публичный API монолита | <module>-api | OpenAPI/AsyncAPI/Protobuf |
| Shared Kernel | пакет shared/ | модуль shared-kernel | избегать |
| Customer–Supplier | редко | между модулями | основной паттерн |
| Conformist | редко | антипаттерн | для стабильных внешних |
| Partnership | по умолчанию | одна команда на 2 модуля | требует координации |
| Separate Ways | разные пакеты | независимые модули | независимые сервисы |
| Domain Events | ApplicationEventPublisher | ApplicationEventPublisher | Kafka + Outbox |
Ссылки
- Стратегические паттерны DDD — Bounded Context, Ubiquitous Language, Context Map.
- Тактические паттерны DDD — Entity, Value Object, Aggregate.
- Выбор начальной архитектуры — критерии выбора монолита или микросервисов.
- Распределённые паттерны — Saga, Outbox, CDC, Idempotent Consumer.
- Apache Kafka — основной транспорт для Domain Events в микросервисах.
- Гексагональная архитектура — где живёт ACL в коде.