Проектируем домен для API: bounded contexts и явные границы ответственности
Соберём доменную модель так, чтобы API отражало смысл, а не “табличную” структуру. Рассмотрим границы контекстов, модели команд/запросов и избегание утечек.
Содержание
Проектируем домен для API: bounded contexts и явные границы ответственности
API почти всегда выглядит «технически», но его качество определяется не фреймворком и не версионированием на уровне URL. В долгоживущих системах API становится интерфейсом домена: он должен говорить на языке бизнеса и удерживать модель от деградации в «табличную магию». Именно здесь доменное проектирование, bounded contexts и дисциплина границ ответственности дают измеримый эффект — меньше утечек, меньше случайных зависимостей, понятнее эволюция.
В этой статье разберём практический подход к проектированию домена для API: как выделять bounded contexts, как формировать модели команд/запросов и как предотвращать утечки терминов и инвариантов между частями системы. Будем говорить достаточно конкретно, чтобы этим можно было пользоваться при проектировании нового сервиса или при исправлении текущих проблем.
Почему API часто «табличное», и к чему это приводит
Типичный антипример выглядит так: у нас есть таблицы users, orders, payments, мы поднимаем CRUD-эндпоинты и начинаем встраивать в контракт API поля, которые соответствуют колонкам.
Проблемы возникают постепенно:
-
API начинает отражать структуру хранения, а не смысл.
Если завтра изменится способ хранения (например, нормализация, шардинг, кэш, архивирование), API начинает «нести» это изменение на себе. -
Инварианты расползаются по слоям.
Проверки вроде “заказ можно отменить только если …” начинают жить в контроллерах, сервисах и хранимых процедурах одновременно. Домен перестаёт быть единственным источником истины. -
Утечки модели между контекстами.
Термины становятся размытыми. Например, в одном местеstatus— это жизненный цикл заказа, а в другом — состояние процесса оплаты. Оба контекста используют одно и то же поле в DTO, потому что «так проще». -
Сложность изменения растёт нелинейно.
Любая правка контракта превращается в расследование: где именно нужно менять поле, как обновить фронт, как мигрировать данные, какие сценарии ломаются.
Чтобы этого избежать, API стоит проектировать как внешнюю проекцию конкретного bounded context, а не как «витрину базы данных».
bounded contexts: основной принцип проектирования
Bounded context — это границы, в которых определены:
- терминология (какие слова и что они означают),
- инварианты (какие правила нельзя нарушить),
- логика поведения (какие действия возможны),
- контракт взаимодействия (какой API говорит именно этот контекст).
Ключевой момент: в разных bounded contexts одна и та же сущность может иметь разное значение. Например, “клиент” в контексте CRM — одно, а “покупатель” в контексте заказов — другое. Даже если внешне это выглядит похоже, инварианты могут отличаться.
Модель границ на практике
В реальном проекте выделить bounded context помогают ответы на вопросы:
1) Где находится единый смысл?
Если вы произносите слово “заказ” и в разных командах вкладывается разный смысл — вероятно, это несколько контекстов.
Если смысл единый и правила совпадают — это кандидат на один bounded context.
2) Какие правила защищают модель?
В каждом bounded context должны быть свои доменные правила.
Если правила зависят от внешних деталей другого контекста (например, статуса платежа), значит границы размываются — либо нужен согласованный контракт, либо события и адаптация.
3) Какая команда отвечает за изменения?
Bounded context — это ещё и организационная граница. Если одно и то же изменение вынуждает координировать 5 команд и перепроверять 10 модулей, возможно, у вас не контекст, а “конвейер в монолите”.
Проектируем домен, чтобы API говорил смыслом, а не таблицами
Когда bounded contexts определены, возникает вопрос: как связать домен и API?
Один из самых практичных подходов — разделить:
- модель команд (commands) / действий — что пользователь/сервис намеревается сделать;
- модель запросов (queries) / чтения — какие данные нужно получить.
Это помогает держать доменные правила и инварианты там, где они принадлежат контексту, а DTO для чтения не превращаются в “карманные объекты” для записи.
Почему разделение команд и запросов полезно именно для границ
Если вы смешиваете “чтение” и “запись” в один объект (например, один DTO используется для POST и GET и содержит все поля), то вы начинаете:
- экспортировать внутренние поля наружу,
- терять контроль над тем, какие действия разрешены,
- случайно привязываться к структуре хранения.
Разделяя модели, проще удерживать границы: команды отражают намерение и инварианты, запросы — проекцию данных для потребителя.
Стратегия: выделяем контексты на уровне API
Рассмотрим типичный домен e-commerce, где часто возникают утечки:
- Checkout / Заказы: формирование и жизненный цикл заказа.
- Payments / Платежи: авторизация, списание, статусы платежных операций.
- Catalog / Каталог (или Pricing): доступность и цены.
- Identity / Пользователи (опционально): учетные записи.
Пример bounded contexts и типовые границы ответственности
1) Контекст “Заказы”
Ответственность:
- создавать заказ,
- управлять его жизненным циклом (draft → submitted → confirmed → cancelled),
- обеспечивать инварианты заказа (например, нельзя подтвердить заказ без подтвержденного состава).
Что API здесь обычно делает:
POST /orders— создать заказ (команда)POST /orders/{id}/confirm— подтвердить (команда)POST /orders/{id}/cancel— отменить (команда)GET /orders/{id}— получить представление заказа (запрос)
Контракт не обязан раскрывать детали расчетов или платежей. Контекст заказа может иметь “ссылки” на внешние операции, но не обязан копировать их правила.
2) Контекст “Платежи”
Ответственность:
- принимать платежные команды (authorize/capture/refund),
- хранить статусы платежных операций,
- обеспечивать инварианты платежного процесса (например, refund возможен только для захваченных платежей).
Контекст платежей может иметь связь с заказами через:
- события (“PaymentAuthorized”, “PaymentCaptured”),
- идентификаторы внешнего агрегата (например,
orderIdкак ссылку).
Критично: правила статусов платежа принадлежат контексту платежей. Контекст заказов не должен “притворяться” платежным движком.
Избегаем утечек: что именно запрещено переносить через границы
Утечка — это не только «неправильные поля в JSON». Это перенос смыслов и инвариантов.
Утечки терминов
Пример:
- в контексте заказов
status=PAIDозначает “заказ оплачен”, - в контексте платежей
status=PAIDозначает “операция платежа завершилась успешно”.
В итоге появляется соблазн использовать один и тот же status в API заказов, ориентируясь на статусы платежей. Это ломает независимость и вызывает ошибки при расхождении моделей.
Правильнее: держать терминологию контекстной. В API заказа можно иметь поле вроде:
paymentState: "pending" | "authorized" | "settled",
и оно должно быть проекцией состояния, а не “скопированным платежным статусом”.
Утечки инвариантов
Если контроллер заказа проверяет условия, относящиеся к контексту платежей (“отменить заказ можно только если платеж еще не захвачен”), значит инварианты разнесены между контекстами.
Правильнее: контекст заказа должен иметь собственные правила, а платежи — предоставлять факты через события/запросы. Даже если бизнес-логика “взаимосвязана”, она не обязана быть “размазана” по кодовой базе одного контекста.
Утечки структуры данных
Если DTO для команды содержит поля “как в таблице платежной операции”, то вы фактически публикуете внутреннее устройство контекста платежей.
Правильнее: команды должны быть минимально достаточными и выражать намерение.
Модели команд: как формировать API действия
Команда — это намерение сделать действие. Важно: команда не обязана отражать агрегат “1 к 1”. Она обязана отражать ограничения и требования, необходимые доменной логике.
Принципы хороших команд
1) Команда должна быть адресована конкретному контексту
Если вы пишете POST /payments/capture, это команда контексту платежей, а не универсальный механизм на все случаи жизни.
2) Команда должна содержать минимальный набор данных
Если контекст может вычислить сумму сам по своим данным — не передавайте ее извне без необходимости.
3) В команде должны отсутствовать внутренние “технические” поля
Например, paymentRowId, processorTxnId — это детали хранения/интеграции.
Пример: модель команды “Списать”
Допустим, контекст платежей должен списать авторизованный платеж.
POST /payments/{paymentId}/capture
{
"amount": 1200,
"currency": "RUB",
"idempotencyKey": "c2f1b6b9-1e4d-4a7b-9a5a-1b3f2c4d5e6f"
}
Что здесь важно:
idempotencyKey— полезен для сетевых дублей,- сумма может быть частичной (если доменная логика поддерживает частичное списание),
paymentId— ссылка на сущность контекста платежей.
Контракт не обязан содержать “status до операции” — домен знает текущее состояние.
Доменные ошибки как часть контракта
Ошибки команды должны быть контекстными и предсказуемыми. Например:
- “PAYMENT_NOT_AUTHORIZED”
- “CAPTURE_ALREADY_DONE”
- “AMOUNT_EXCEEDS_AUTHORIZED”
Это даёт потребителю API возможность корректно реагировать, не залезая в детали реализации.
Модели запросов: проекция данных без разрушения домена
Запросы обычно проще: они возвращают данные. Но и здесь границы важны: представление должно соответствовать потребностям клиента, а не внутренним сущностям.
Правильный контракт запроса — это проекция
Например, запрос GET /orders/{id} может вернуть:
orderIdstate(жизненный цикл заказа)items(состав)payment(проекция состояния оплаты)total(если проекция нужна)
Но не стоит возвращать “сырой внутренний платежный статус”, если его смысл другой.
Пример: представление заказа
GET /orders/ORD-100500
{
"orderId": "ORD-100500",
"state": "confirmed",
"items": [
{ "sku": "T-SHIRT-XL", "qty": 1, "price": 1200 }
],
"payment": {
"state": "authorized",
"lastUpdatedAt": "2026-07-22T10:15:30Z"
},
"total": 1200
}
Обратите внимание: payment.state здесь — именно проекция, которая принадлежит доменному языку заказа (а не платежного контекста).
Интеграция контекстов: события, анти-утечки и адаптация
Когда bounded contexts разделены, они всё равно должны взаимодействовать. Основная проблема — как передавать изменения, не ломая модель.
Типовой механизм: доменные события
Контекст платежей публикует события:
PaymentAuthorizedPaymentCapturedPaymentFailed
Контекст заказов подписывается и обновляет свои проекции/состояния.
Проблема возникает, когда событие несёт слишком много “внутреннего”. Поэтому важен дизайн событий.
Принципы событий
-
Событие описывает факт произошедшего, а не внутреннюю процедуру.
Например, “PaymentCaptured” — факт. “PaymentRowUpdated” — внутреннее. -
Событие содержит контекстные данные, нужные получателю.
Контекст заказа может не знать “процессор”, но ему нуженamount,currency(если это важно для инвариантов) и ссылка наorderId. -
Не публикуйте внутренние структуры и таблицы.
Пример события (контекст платежей → контекст заказов)
{
"type": "PaymentCaptured",
"occurredAt": "2026-07-22T10:15:30Z",
"paymentId": "PAY-9001",
"orderId": "ORD-100500",
"amount": 1200,
"currency": "RUB",
"reference": "psp_ref_12345"
}
Контекст заказа по этому факту может перевести payment.state в settled или выполнить действия (например, подтвердить заказ, если это правило домена).
Где адаптеры уместны
Иногда интеграция требует маппинга: один контекст говорит на своём языке, другой ожидает другой смысл. Адаптация делается на границе:
- преобразуем события в модель получателя,
- переводим терминологию,
- применяем правила корректного соответствия.
Синхронные запросы между контекстами: когда можно, когда нельзя
События — хороший выбор для асинхронного согласования, но иногда нужна синхронная проверка (например, “можно ли выполнить действие прямо сейчас?”).
Синхронные вызовы между bounded contexts должны быть редкими и ограниченными.
Риски синхронной интеграции
-
Жёсткая связность.
Модель одного контекста вынуждена “жить” в ответах другого. -
Дублирование инвариантов.
Логика начинает проверять внешние условия через API, вместо того чтобы опираться на свои правила и события.
Компромисс: “контракт факта”, а не “контракт модели”
Если нужно убедиться в факте, лучше проектировать endpoint как проверку факта:
GET /orders/{id}/payment-state— возвращает проекцию,- или
POST /orders/{id}/validate— команда в контекст заказа, а не запрос к платежам из контроллера.
Практика: как не запутаться в API-структуре
Даже при корректном домене остаётся риск запутать внешнего потребителя: endpoints превращаются в набор “ручек”, где имена не отражают смысл.
О чём помнить при проектировании маршрутов
Не делайте ресурсы “табличными”
Плохо:
GET /payment_transactions/{id}— если потребителю нужен смысл “оплата заказа”, а не таблица транзакций.
Лучше:
GET /orders/{id}с проекцией оплаты,- или
GET /payments/{paymentId}в контексте платежей.
Именуйте действия как намерение
Плохо:
POST /orders/{id}/updatePaymentStatus
Лучше:
POST /orders/{id}/confirmPOST /orders/{id}/cancel
Версионирование и стабильность
Bounded context помогает стабильности: контракт внешнего API связан с контекстом и его языке. Если меняется внутренняя реализация, контракт может не требовать изменений. Версионирование — следствие реальной смены смысла, а не следствие смены таблиц.
Минимальный референс-процесс: как организовать работу над доменом и API
Ниже — практический workflow, который хорошо работает в команде:
Шаг 1. Выпишите словарь домена
- Какие термины используются?
- Кто за них отвечает?
- Что именно означает каждое понятие (инварианты)?
Шаг 2. Выделите bounded contexts и границы
Для каждого контекста:
- определите командные сценарии (что делают),
- определите запросы (что показывают),
- определите события (что публикуют и что потребляют).
Шаг 3. Проектируйте контракты от смыслов, а не от таблиц
Сначала — команды (intent), затем — запросы (projection).
DTO для команд и запросов могут быть разными.
Шаг 4. Зафиксируйте “запреты” на утечки
Например:
- нельзя возвращать платежные статусы как есть в API заказов;
- нельзя принимать в командах поля, которые являются внутренними идентификаторами хранения.
Шаг 5. Добавьте адаптацию на границе
Маппинг событий → проекции → контекстные модели.
Типичные ошибки при проектировании bounded contexts и API
Ошибка 1: “Всё — один контекст”
Когда команда пытается покрыть весь домен одним набором сущностей и DTO, bounded context отсутствует на практике. API разрастается, а инварианты начинают конкурировать.
Симптомы:
- команды начинают знать слишком много про другие части системы,
- DTO содержат “половину базы”,
- ошибки трудно объяснить доменной логикой.
Ошибка 2: “Контекст есть на диаграмме, но нет в коде”
Название payments и orders в папках ещё не означает bounded context. Если правила, терминология и модели смешиваются, утечки продолжатся.
Симптомы:
- один доменный объект используется для разных смыслов,
- события содержат внутренние структуры,
- контроллеры продолжают содержать бизнес-логику.
Ошибка 3: Использование единого DTO между командами и запросами
Это приводит
Комментарии
Пока нет комментариев