Интеграционные паттерны DDD

Интеграционные паттерны DDD: ACL, Open Host Service, Published Language.

интеграционные паттерны DDD

Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс.


Интеграционные паттерны 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 Objectleaky 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-клиент с маппингом.

diagram
@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/v2semver 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
Стоимость измененияминимальнаяпересборка модулейпересборка всех сервисов (плохо)
Рискlowmediumhigh — 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 — формальный потребитель.

diagram

Контрактные тесты — обязательны. Supplier публикует provider contract, customer — consumer contract, пайплайн проверяет совместимость.

Сравнение

СлойныйМодульныйМикросервисы
Где живёт контрактJava-интерфейсsupplier-api модульOpenAPI / Pact
Как ловится breaking changeкомпиляторкомпиляторконтракт-тесты в CI
SLAn/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 в большой системе. Два сервиса не знают друг о друге. Дублирование данных — норма.

diagram

Дублирование? Да. Но зато полная независимость деплоев, релизов, инцидентов.

Сравнение

СлойныйМодульныйМикросервисы
Стоимость дублированиястроки кодатаблицы в БДцелые подсистемы
Когда уместноразные доменынезависимые модулинезависимые команды

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. Дубликаты возможны — потребитель должен быть идемпотентным. Порядок — только в рамках одной партиции (по ключу).

Сравнение

СлойныйМодульныйМикросервисы
ТранспортApplicationEventPublisherApplicationEventPublisherKafka / RabbitMQ
Гарантиитранзакционныетранзакционныеat-least-once + Outbox
Идемпотентностьне нужнане нужнаобязательна
Где хранятся событиянигде (in-memory)нигдетопик брокера + БД (event store)
Стоимость инфры00Kafka-кластер

Дерево решений

diagram

Антипаттерны (общие для всех архитектур)

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>-apiOpenAPI/AsyncAPI/Protobuf
Shared Kernelпакет shared/модуль shared-kernelизбегать
Customer–Supplierредкомежду модулямиосновной паттерн
Conformistредкоантипаттерндля стабильных внешних
Partnershipпо умолчаниюодна команда на 2 модулятребует координации
Separate Waysразные пакетынезависимые модулинезависимые сервисы
Domain EventsApplicationEventPublisherApplicationEventPublisherKafka + Outbox

Ссылки