Когда система вырастает, её делят на несколько независимых частей — Bounded Context. Каждая часть отвечает за свой кусок предметной области: заказы, доставка, оплата. Но части всё равно должны как-то взаимодействовать.
Здесь и возникает главный вопрос: как одна часть системы разговаривает с другой? От ответа зависит, насколько они будут независимы, кто диктует правила изменений и насколько дорого обходится поддержка.
DDD описывает семь паттернов интеграции. Разберём каждый простыми словами.
Anti-Corruption Layer — защитный слой-переводчик
Представьте: ваш код заказов должен получать данные из старой платёжной системы. У неё своя терминология, свои поля, свой формат. Если тянуть её понятия прямо в свой код — в итоге ваш домен заказов начнёт говорить на языке платёжной системы. Поменяется платёжная — придётся переписывать домен.
Anti-Corruption Layer (ACL) — это слой-переводчик. Он принимает «чужой» формат и переводит его в понятия вашего домена. Снаружи — чужой мир, внутри — ваши типы.
// Порт — ваш домен описывает, что ему нужно (internal/domain)
type PaymentGateway interface {
PaymentStatus(ctx context.Context, id PaymentOrderID) (PaymentOrder, error)
}
// ACL-адаптер — переводит чужой ответ в ваш тип (internal/adapter/sber)
type SberPaymentAdapter struct {
client *SberClient
}
func (a *SberPaymentAdapter) PaymentStatus(ctx context.Context, id domain.PaymentOrderID) (domain.PaymentOrder, error) {
resp, err := a.client.OrderStatus(ctx, id.Value())
if err != nil {
return domain.PaymentOrder{}, err
}
status, err := mapStatus(resp.OrderStatus)
if err != nil {
return domain.PaymentOrder{}, err
}
return domain.NewPaymentOrder(
domain.NewPaymentOrderID(resp.OrderID),
domain.MoneyFromKopecks(resp.Amount),
status,
), nil
}
Смена платёжного провайдера затрагивает только адаптер. Домен заказов ничего не знает о Сбере — он работает через интерфейс PaymentGateway.
ACL применяется везде, где граница между «нашей» и «чужой» моделью существенна: внешние API, сторонние сервисы, соседние команды. В микросервисной архитектуре ACL — стандарт для каждой внешней интеграции.
Open Host Service — публичный API с устойчивым форматом
Обратная ситуация: не вы потребляете чужое, а другие потребляют ваше. Если вы просто «открываете» внутренние типы наружу — любое изменение модели ломает клиентов.
Open Host Service (OHS) — это когда контекст публикует стабильный документированный API. Внутренняя модель может меняться, а API остаётся предсказуемым.
Published Language — конкретный формат этого API: стабильные DTO, OpenAPI-схема, версионированный контракт.
// Стабильный контракт — Published Language
type OrderJSON struct {
OrderID uuid.UUID `json:"orderId"`
Status string `json:"status"`
TotalAmount decimal.Decimal `json:"totalAmount"`
Currency string `json:"currency"`
CreatedAt time.Time `json:"createdAt"`
}
// HTTP-хендлер публикует API, не доменную модель
func (h *OrderAPIHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
order, err := h.queries.OrderByID(r.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(toOrderJSON(order)) // маппер изолирует домен от контракта
}
Клиенты зависят от OrderJSON, а не от внутреннего типа Order. Добавили поле в Order — OrderJSON не меняется, клиенты не ломаются.
В микросервисах это оформляется как OpenAPI-спецификация с версией в URL (/api/v1/, /api/v2/). Контракт проверяется контракт-тестами в CI.
Customer–Supplier — поставщик и потребитель
Один контекст поставляет данные или функциональность, другой потребляет. Поставщик (Supplier) решает, как и когда менять контракт. Потребитель (Customer) влияет на приоритеты, но не диктует условия.
Классический пример: сервис каталога поставляет данные о продуктах, сервис заказов их потребляет.
// Supplier объявляет интерфейс
type ProductQueries interface {
FindByID(ctx context.Context, id ProductID) (*Product, error)
}
// Customer получает его через внедрение зависимости
type CreateOrderHandler struct {
products ProductQueries
// ...
}
В микросервисах поставщик публикует REST или Kafka API, поддерживает SLA, ведёт журнал изменений. Потребитель — формальный клиент. Breaking changes поставщик согласует заранее.
Ключевое правило: изменение контракта — ответственность поставщика. Потребитель следит за журналом изменений, контракт-тесты проверяют совместимость автоматически.
Conformist — принятие чужой модели как есть
Иногда самый простой выход — использовать модель другой системы напрямую, без перевода. Это паттерн Conformist: мы принимаем чужой формат данных и не создаём свою модель поверх него.
Когда это оправдано: внешний API стабилен, меняется редко, его модель вас полностью устраивает.
// Используем модель ЦБ РФ напрямую — меняется раз в годы
type CbrCurrencyRate struct {
Code string
Rate decimal.Decimal
Date time.Time
}
type CurrencyService struct {
cbrClient *CbrClient
}
func (s *CurrencyService) Rate(ctx context.Context, code string) (CbrCurrencyRate, error) {
return s.cbrClient.Rate(ctx, code) // никакого маппинга
}
Conformist — это осознанный выбор, не лень. Если чужая модель начала меняться, в ваш домен стали проникать чужие термины, или вы хотите поддерживать несколько версий API — пора строить ACL.
Внутри системы (между своими модулями или сервисами) Conformist почти всегда плохая идея: ACL внутри одного процесса почти бесплатный, зато даёт изоляцию.
Shared Kernel — общая часть модели
Некоторые типы фундаментальны и нужны нескольким контекстам: идентификаторы, денежные суммы, базовые перечисления. Дублировать их в каждом пакете неудобно.
Shared Kernel — небольшая общая часть модели, которой совместно владеют контексты.
sharedkernel/
├── ids/user_id.go
├── ids/order_id.go
└── money/money.go
Правило: в Shared Kernel только фундаментальные типы без бизнес-логики. Никаких агрегатов, никаких правил домена. Изменение типа в Shared Kernel требует согласования всех команд, которые его используют.
В монолите это просто общий пакет. В микросервисах — осторожно: общая библиотека (Go-модуль) связывает деплои всех сервисов. Если поменяли Money — надо пересобрать и выкатить все сервисы одновременно. Это называют «распределённым монолитом» и считают проблемой. Альтернатива: дублировать тип в каждом сервисе и передавать примитивы (uuid.UUID, string) на границах.
Partnership — совместная разработка
Два контекста развиваются вместе: обе команды совместно согласовывают изменения контракта, релизы координируются.
Это уместно, когда два модуля разрабатывает одна команда или две команды очень тесно сотрудничают. Нет формального «поставщика» — обе стороны равноправны.
// Оба модуля согласовали добавление нового поля
type CreateOrderRequest struct {
CustomerID uuid.UUID `json:"customerId"`
Items []OrderItemRequest `json:"items"`
WarehouseID uuid.UUID `json:"warehouseId"` // ← добавлено совместно Orders + Inventory
}
Риск: если команды разойдутся или появятся новые менеджеры — совместная разработка превращается в неконтролируемую связность. Сигнал к смене паттерна: ввести версионирование и перейти к Customer–Supplier.
Separate Ways — независимые пути
Иногда два контекста просто не должны знать друг о друге. Они решают разные задачи, даже если используют похожие данные.
Пример: сервис уведомлений и сервис аналитики оба работают с «пользователями», но для совершенно разных целей. Никакой интеграции между ними нет.
Notification Service — своя таблица пользователей (email, телефон, настройки)
Analytics Service — своя таблица посетителей (сегмент, последний визит)
Дублирование данных? Да. Но зато полная независимость: релизы, инциденты, деплои — всё отдельно. Иногда это правильный выбор.
Domain Events — как контексты сообщают о том, что произошло
Domain Events — не отдельный паттерн выбора связности, а механизм коммуникации. Контекст публикует событие о том, что произошло («Заказ подтверждён»), другой контекст реагирует.
В монолите события передаются внутри процесса через event bus — в Go это небольшой собственный интерфейс EventBus с публикацией после коммита транзакции:
type ConfirmOrderHandler struct {
orders OrderRepository
events EventBus
tx TxManager
}
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
return h.tx.WithinTx(ctx, func(ctx context.Context) error {
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return err
}
if err := order.Confirm(); err != nil {
return err
}
if err := h.orders.Save(ctx, order); err != nil {
return err
}
h.events.PublishAfterCommit(ctx, NewOrderConfirmed(order.ID(), order.Total()))
return nil
})
}
type ShippingListener struct{}
func (l *ShippingListener) OnOrderConfirmed(ctx context.Context, e OrderConfirmed) {
// Выполняется только после успешного коммита заказа
}
Гарантия: если транзакция откатилась — слушатель не вызовется. PublishAfterCommit откладывает публикацию до успешного коммита и гарантирует, что реакция произойдёт только при успешном сохранении.
В микросервисах события передаются через брокер сообщений (Kafka, RabbitMQ). Здесь возникает проблема: что если сервис сохранил заказ в базу, но упал до публикации события в Kafka? Событие потеряно.
Решение — Transactional Outbox: событие записывается в ту же базу данных в той же транзакции. Отдельный процесс (relay) читает события из базы и публикует в Kafka.
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
return h.tx.WithinTx(ctx, func(ctx context.Context) error {
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return err
}
if err := order.Confirm(); err != nil {
return err
}
if err := h.orders.Save(ctx, order); err != nil {
return err
}
// Outbox — в той же транзакции, что и сохранение заказа
return h.outbox.Save(ctx, NewOutboxEvent(
"order.confirmed.v1",
order.ID().String(),
OrderConfirmedV1{OrderID: order.ID(), Total: order.Total()},
))
})
}
// Отдельная горутина читает outbox и публикует в Kafka
func (r *OutboxRelay) Run(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.publishPending(ctx)
}
}
}
func (r *OutboxRelay) publishPending(ctx context.Context) {
events, err := r.outbox.FindPending(ctx)
if err != nil {
return
}
for _, e := range events {
if err := r.kafka.Send(ctx, "order-events", e.Payload); err != nil {
return
}
r.outbox.MarkPublished(ctx, e.ID)
}
}
Важно: Kafka доставляет события минимум один раз (at-least-once). Дубликаты возможны. Потребитель должен быть идемпотентным — проверять, не обработано ли событие уже.
Как выбрать паттерн
Простое дерево решений:
- Нет интеграции → Separate Ways
- Внешняя система, их модель устраивает → Conformist
- Внешняя система, нужна защита домена → Anti-Corruption Layer
- Один поставляет, много потребляют → Open Host Service + Published Language
- Один поставляет, один потребляет → Customer–Supplier
- Одна команда, два модуля → Partnership
- Общие базовые типы → Shared Kernel (осторожно в микросервисах)
Частые ошибки
ACL «протекает». ACL принимает чужой тип и возвращает его наружу — защита не работает. Всегда маппируйте до границы ACL, наружу — только свои типы.
Shared Kernel разросся. В «общий» пакет попала бизнес-логика, агрегаты, правила. Теперь все зависят от всего. Shared Kernel — только фундаментальные примитивы.
Temporal coupling. Синхронная цепочка: сервис A вызывает B, B вызывает C. Упал C — сломалось всё. Domain Events + асинхронная обработка разрывают цепочку.
God Context. Один контекст знает про всех: хранит клиентов, заказы, доставку, оплату. Нет смысла в Bounded Context — всё в одном месте. Делите по ответственности.
Коротко
- ACL — слой-переводчик между чужой моделью и вашим доменом. Защищает домен от внешних изменений.
- Open Host Service + Published Language — стабильный публичный API, отделённый от внутренней модели.
- Customer–Supplier — один поставляет, другой потребляет. Поставщик управляет контрактом.
- Conformist — принятие чужой модели как есть. Оправдан для стабильных внешних API.
- Shared Kernel — общие базовые типы. В монолите удобен, в микросервисах опасен.
- Partnership — совместная разработка двух контекстов одной командой.
- Separate Ways — без интеграции. Полная независимость ценой дублирования.
- Domain Events — механизм оповещения: в монолите через event bus, в микросервисах через брокер + Transactional Outbox.
Что почитать дальше
- Стратегические паттерны DDD — Bounded Context, Ubiquitous Language, Context Map.
- Тактические паттерны DDD — Entity, Value Object, Aggregate.
- Apache Kafka — основной транспорт для Domain Events в микросервисах.