Logo
Overview

Bounded Context в DDD: как разделить домен на границы

October 12, 2026
7 min read

Когда я впервые столкнулся с DDD на проекте интернет-магазина, самая болезненная точка была не в терминологии и не в паттернах. Самое сложное — понять, где провести черту. Где заканчивается «Заказ» и начинается «Доставка»? Почему «Товар» в каталоге и «Товар» в корзине — это два разных понятия, хотя называют их одинаково?

Bounded Context — это не про технические границы. Это про смысл.

Bounded Context (ограниченный контекст) — явная граница, внутри которой определённая модель предметной области имеет однозначный смысл и не зависит от моделей в других контекстах.

Без таких границ вы получаете God-модель: одна сущность Product, которая пытается угодить каталогу, складу, ценообразованию и рекомендациям одновременно. Знакомо?

Почему без контекстов всё ломается

В типичном интернет-магазине слово «товар» означает разное в разных отделах. Для маркетинга важны описание, фото и SEO. Для склада — габариты, вес и ячейка хранения. Для бухгалтерии — себестоимость, НДС и поставщик.

Когда вы запихиваете всё это в одну модель, получается монстр на 50 полей. Половина из них null в зависимости от сценария. Разработчики боятся трогать Product.java, потому что непонятно, что отвалится.

Я видел проект, где сущность «Пользователь» содержала 83 поля. Там было всё: от хеша пароля до любимого цвета для рекомендаций. Каждый чих в профиле требовал регресса по всему приложению.

Bounded Context решает это радикально: у каждой части системы — своя модель. Свои поля, своя логика, свои инварианты. Одна и та же сущность в разных контекстах — это разные объекты.

Как выглядит разделение на контексты

Возьмём интернет-магазин и выделим ключевые контексты.

100%
graph TD
  subgraph CatalogContext["Контекст Каталог"]
      P1["Product
название, описание, фото, цена"]
  end
  subgraph InventoryContext["Контекст Склад"]
      P2["StockItem
SKU, количество, ячейка, поставщик"]
  end
  subgraph CartContext["Контекст Корзина"]
      P3["CartItem
productId, количество, цена на момент"]
  end
  subgraph OrderContext["Контекст Заказ"]
      P4["OrderLine
productId, цена продажи, скидка"]
  end
  subgraph PricingContext["Контекст Ценообразование"]
      P5["PriceRecord
базовая цена, акция, сегмент, период"]
  end

  CatalogContext -- "ProductCreated
event" --> InventoryContext
  CatalogContext -- "ProductCreated
event" --> PricingContext
  CatalogContext -.-> CartContext
  CartContext -- "CheckoutRequested
event" --> OrderContext
  OrderContext -- "OrderPlaced
event" --> InventoryContext

  style CatalogContext fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style InventoryContext fill:#50c878,stroke:#3a9a5c,color:#fff
  style CartContext fill:#f0a500,stroke:#c88400,color:#fff
  style OrderContext fill:#7b68ee,stroke:#5a4db2,color:#fff
  style PricingContext fill:#e0e0e0,stroke:#999,color:#333
  style P1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style P2 fill:#50c878,stroke:#3a9a5c,color:#fff
  style P3 fill:#f0a500,stroke:#c88400,color:#fff
  style P4 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style P5 fill:#e0e0e0,stroke:#999,color:#333

На диаграмме пять контекстов. Обратите внимание: «товар» в каждом — своя модель со своим набором полей. Контекст «Каталог» хранит название и описание, а контекст «Склад» — SKU и ячейку. Между собой контексты общаются событиями. ProductCreated сообщает складу и ценообразованию о новом товаре, OrderPlaced резервирует остатки.

Точечная линия от каталога к корзине — это не событие, а ссылка по идентификатору. Корзина не хранит описание товара, только productId. Если нужно название — запрашивает из каталога.

Как искать границы: три практических приёма

Хрестоматийные советы про «посмотрите на оргструктуру» и «ищите разный язык» — это верно, но недостаточно. На реальном проекте я использую три конкретных техники.

Приём 1: Тест на изменение

Задайте вопрос: «Если поменяется бизнес-правило X, какой код затронет?»

Если правило «цена товара зависит от сегмента клиента» живёт в той же модели, что и «товар имеет название и описание» — граница размыта. Изменение ценообразования не должно трогать каталог. Если трогает — режьте.

Практический тест: представьте, что один контекст отдаёте другой команде. Насколько сильно им придётся созваниваться? Если каждый спринт — это плохой разрез.

Приём 2: Анализ языка

Соберите с десяток пользовательских историй или требований и выпишите все существительные, которые используют стейкхолдеры. Одно и то же слово в разных отделах?

СловоМаркетингСкладБухгалтерия
ТоварКарточка с описанием и фотоФизическая единица на полкеОбъект учёта с себестоимостью
ЗаказКонверсия, источник трафикаЗадание на сборкуФинансовый документ
КлиентСегмент, LTV, каналАдрес доставки, контактыПлательщик, договор

Три разных значения слова «Товар» — три разных контекста. Таблица сама показывает, где резать.

Приём 3: Правило одного источника правды

Каждый факт в системе должен иметь одного владельца. Цена товара меняется — кто source of truth? Если цена хранится и в каталоге, и в ценообразовании, и в заказе, вы обречены на рассинхрон.

В правильно нарезанных контекстах каждый атрибут принадлежит ровно одному контексту. Остальные либо ссылаются по ID, либо хранят копию на момент транзакции (как цена в заказе — это исторический снимок, не живая цена).

Типичные ошибки дробления

За годы практики я наступал на все эти грабли. Собрал самые частые.

Слишком мелко. Разрезать контекст до одного класса — перебор. Если между «контекстами» нет разных бизнес-правил, а только техническое разделение — это модули, не Bounded Context. Контекст — про бизнес-смысл, не про структуру кода.

Слишком крупно. God-контекст на всю «Клиентскую базу». Внутри уживаются профили, сегментация, история покупок и аутентификация. Результат: 40+ таблиц в одной схеме, команды блокируют друг друга, деплой раз в месяц со слезами.

Смешивание контекстов через общую БД. Видел проект, где «Заказы» и «Доставка» делили одну базу. Поле status означало разное в зависимости от контекста: в заказе — PAID, в доставке — IN_TRANSIT. Документация говорила «ориентируйтесь по соседним полям». Это не архитектура, это ребус.

События с избыточными данными. Контекст «Склад» получает событие ProductCreated и в него запихивают все 30 полей товара. Складу нужно два: productId и SKU. Остальное — информационный шум. Событие должно нести ровно то, что нужно подписчику для его бизнес-логики.

Отсутствие явных точек интеграции. Контексты живут сами по себе, но где-то всё равно происходит магия по перекладыванию данных. Обычно это скрыто в хранимых процедурах, триггерах или скриптах миграции. Точки интеграции должны быть явными: либо события, либо API, либо антикоррупционный слой.

Как Bounded Context связан с остальным DDD

Bounded Context не живёт в вакууме. Он — фундамент для остальных тактических паттернов DDD.

Агрегат и корень агрегата определяют границы транзакций внутри контекста. Каждый агрегат — это часть контекста, которая обеспечивает инварианты. Нельзя сделать агрегат, который ссылается на сущность из другого контекста (только по ID).

Ubiquitous Language развивается независимо в каждом контексте. Слово «Клиент» в контексте «Продажи» и в контексте «Поддержка» — это два разных ubiquitous language. Именно ради этого единства языка и нужна граница: внутри контекста все называют вещи одинаково.

Более широкий взгляд на DDD с примерами из банковской системы — в разборе DDD с банковским кейсом.

Контекстные карты: как показывать связи между контекстами

После того как контексты выделены, нужно показать, как они взаимодействуют. Для этого в DDD есть Context Map — карта контекстов с типами отношений.

100%
graph LR
  Catalog["Каталог
U"]
  Inventory["Склад
D"]
  Pricing["Ценообразование
D"]
  Cart["Корзина
U"]
  Order["Заказы
U"]
  Delivery["Доставка
CF"]

  Catalog -- "U/D: ProductCreated" --> Inventory
  Catalog -- "U/D: ProductCreated" --> Pricing
  Cart -- "U/D: CheckoutRequested" --> Order
  Order -- "U/D: OrderPlaced" --> Inventory
  Order -- "PS: ShipmentRequested API" --> Delivery
  Catalog -.-> Cart

  style Catalog fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style Inventory fill:#50c878,stroke:#3a9a5c,color:#fff
  style Pricing fill:#e0e0e0,stroke:#999,color:#333
  style Cart fill:#f0a500,stroke:#c88400,color:#fff
  style Order fill:#7b68ee,stroke:#5a4db2,color:#fff
  style Delivery fill:#50c878,stroke:#3a9a5c,color:#fff

Ключевые типы отношений на карте:

  • Upstream/Downstream (U/D) — Каталог (upstream) диктует модель событий, Склад и Ценообразование (downstream) под неё подстраиваются. Upstream не знает о downstream, а downstream зависит от upstream.
  • Partnership (PS) — Заказы и Доставка договариваются о контракте на равных. Обе стороны влияют на API.
  • Conformist (CF) — Доставка полностью принимает модель данных Заказов без изменений. Нет смысла мапить поля, если команда доставки маленькая.
  • Shared Kernel — два контекста делят общий кусок модели (например, Money или Address). Режет только когда выгода перевешивает риск.
  • Anti-Corruption Layer (ACL) — адаптер между устаревшей системой и вашим контекстом, чтобы чужой хаос не просочился внутрь.

Чек-лист для аналитика

Перед тем как рисовать контексты на доске, пройдитесь по этому списку:

  • Один и тот же термин в разных отделах означает разное? → Разные контексты.
  • Есть бизнес-правило, которое меняет код в двух разных модулях одновременно? → Плохой разрез, режьте иначе.
  • У каждого атрибута один владелец (source of truth)? → Проверьте, что price не живёт в трёх контекстах.
  • Контекст можно отдать другой команде без ежедневных созвонов? → Если нет, граница не работает.
  • Точки интеграции между контекстами явные (события, API, ACL)? → Никаких триггеров в БД и хранимок-мостов.
  • Bounded Context не меньше модуля и не больше поддомена? → Контекст — не класс и не вся система.

Этот чек-лист не гарантирует идеальный разрез с первого раза. DDD — итеративный процесс. Через три месяца работы вы пересмотрите границы, и это нормально. Архитектура — не каменная табличка, а живой организм.

Главное правило: если две части системы меняются по разным причинам и в разном темпе — они должны жить в разных Bounded Context. Всё остальное — детали реализации.