Logo
Overview

DDD: bounded context, aggregate и ubiquitous language — разбор на примере банковской системы

June 1, 2026
10 min read

DDD — это не про то, чтобы выучить три определения и кивать на собеседованиях. Это про то, чтобы вся команда — от аналитика до разработчика — думала о системе одинаково. А для этого мало знать слова «bounded context», «aggregate» и «ubiquitous language». Нужно увидеть, как они работают на реальном примере.

Если общая картина DDD вам ещё незнакома, загляните в обзорную статью — там разбирается, кому и зачем это нужно. А если после неё термины всё ещё путаются, есть разбор DDD простыми словами с понятными аналогиями. Здесь же мы пойдём другим путём: возьмём один сквозной домен и пройдём его полностью — от первого слова бизнеса до архитектурной схемы.

Домен — банковский кредитный конвейер. Почему он? Потому что это классический сложный домен: много участников, жёсткие правила, регулирование. Идеальный полигон для DDD.

Ubiquitous Language: когда «заявка» — это не «анкета» и не «обращение»

Первое, с чего начинается DDD в любом проекте — единый язык. Не глоссарий в Confluence, который никто не читает. А живое соглашение о том, как называются вещи.

Ubiquitous Language — это договорённость: бизнес, разработка и тестирование называют одну и ту же сущность одним и тем же словом. Всегда.

В банке с этим беда. Кредитный менеджер говорит «заявка». Бэкенд-разработчик в коде пишет CreditRequest. Аналитик в требованиях использует «обращение». Фронтендер рисует форму и вешает класс loan-application-form. Четыре названия для одного и того же — и это только начало.

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

Собираем ключевых участников: продакт-оунера, кредитного методолога, системного аналитика и техлида. Проводим event storming на 2–3 часа. Выписываем все события, которые происходят с кредитной заявкой от подачи до выдачи.

Вот что получается после первого раунда:

Термин бизнесаКод (до DDD)Код (после DDD)Определение
Кредитная заявкаCreditRequestLoanApplicationСовокупность анкеты заёмщика, запрашиваемых параметров кредита и документов
ЗаёмщикCustomerBorrowerФизическое лицо, подавшее заявку (не путать с Client — это более широкое понятие)
Скоринговый баллscoreScoringResult.scoreЧисловая оценка кредитоспособности от 300 до 850
Решение по заявкеstatusUnderwritingDecisionИтог андеррайтинга: одобрено / отказано / требуется доп. проверка
Кредитный договорContractLoanAgreementЮридический документ с графиком платежей, ставкой и полной стоимостью кредита

Примечательно, что половина проблем возникает не от недостатка знаний, а от того, что люди просто называют одно разными словами. После такого стола в коде появляются осмысленные имена, и главное — бизнес их узнаёт. Кредитный методолог открывает PR и видит там Borrower, LoanAgreement, ScoringResult — и ему не нужно переспрашивать аналитика, «что здесь происходит». Он и так понимает.

Bounded Context: где заканчивается одна модель и начинается другая

Теперь — самое важное. Когда единый язык зафиксирован, мы обнаруживаем, что не все термины живут в одном пространстве.

Bounded Context — это логическая граница, внутри которой термин имеет ровно одно значение. За пределами границы — другое значение. Или вообще другая модель.

В кредитном конвейере таких границ минимум пять:

100%
graph TD
  subgraph CTX_APPLICATION["Контекст: Кредитные заявки"]
      direction TB
      APP["Кредитная заявка<br/><i>LoanApplication</i>"]
      APP_D["анкета, паспорт, доход,<br/>запрашиваемая сумма, срок"]
      APP --- APP_D
  end

  subgraph CTX_SCORING["Контекст: Скоринг"]
      direction TB
      SCR["Скоринговая модель<br/><i>ScoringEngine</i>"]
      SCR_D["кредитная история,<br/>долговая нагрузка,<br/> скоринговый балл"]
      SCR --- SCR_D
  end

  subgraph CTX_UNDERWRITING["Контекст: Андеррайтинг"]
      direction TB
      UND["Решение по заявке<br/><i>UnderwritingDecision</i>"]
      UND_D["одобренная сумма,<br/>ставка, срок,<br/>причина отказа"]
      UND --- UND_D
  end

  subgraph CTX_AGREEMENT["Контекст: Кредитный договор"]
      direction TB
      AGR["Договор<br/><i>LoanAgreement</i>"]
      AGR_D["график платежей,<br/>полная стоимость кредита,<br/>страховки"]
      AGR --- AGR_D
  end

  subgraph CTX_DISBURSEMENT["Контекст: Выдача"]
      direction TB
      DISB["Выдача кредита<br/><i>Disbursement</i>"]
      DISB_D["сумма, счёт зачисления,<br/>дата выдачи,<br/>назначение платежа"]
      DISB --- DISB_D
  end

  APP -->|"applicationId"| SCR
  SCR -->|"scoringResult"| UND
  UND -->|"decision"| APP
  APP -->|"approvedApplication"| AGR
  AGR -->|"agreementId"| DISB

  style APP fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style SCR fill:#f0a500,stroke:#c88400,color:#fff
  style UND fill:#7b68ee,stroke:#5a4db2,color:#fff
  style AGR fill:#50c878,stroke:#3a9a5c,color:#fff
  style DISB fill:#50c878,stroke:#3a9a5c,color:#fff
  style APP_D fill:#fff,stroke:#2c5f8a,color:#333
  style SCR_D fill:#fff,stroke:#c88400,color:#333
  style UND_D fill:#fff,stroke:#5a4db2,color:#333
  style AGR_D fill:#fff,stroke:#3a9a5c,color:#333
  style DISB_D fill:#fff,stroke:#3a9a5c,color:#333

Диаграмма показывает пять bounded contexts кредитного конвейера. Каждый — со своей моделью и атрибутами. Связи между контекстами — через идентификаторы (applicationId, agreementId), а не через общие объекты. Заявка (синий) входит в скоринг (жёлтый) и получает результат. Андеррайтинг (фиолетовый) выносит решение и возвращает его обратно в заявку. Одобренная заявка переходит в договор (зелёный), а договор — в выдачу.

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

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

Разные команды. Скорингом занимается риск-аналитика — они калибруют модель, подключают бюро кредитных историй. Андеррайтингом — кредитные эксперты, которые принимают решения по сложным случаям. Им не нужен доступ к исходному коду скоринга, как и скорингу — к правилам андеррайтинга.

Разная частота изменений. Кредитный договор — стабильный юридический шаблон, меняется раз в квартал (если регулятор не подкинет сюрприз). Скоринговая модель перекалибровывается ежемесячно на свежих данных. Если всё это лежит в одном коде — при каждом изменении скоринговой модели вы рискуете сломать договор. Неудобно, да?

Разные бизнес-правила. Выдача кредита требует взаимодействия с банковским ядром, АБС и платёжным шлюзом — это чисто технический контекст. Кредитная заявка — это пользовательский интерфейс, валидация полей, загрузка документов. Им нечего делать вместе.

Пять контекстов — не догма. Для ипотеки, например, добавится контекст «Оценка залога» (там взаимодействие с БТИ, оценщиками, Росреестром). Для кредитных карт — контекст «Лимиты и обслуживание». Количество контекстов растёт ровно настолько, насколько усложняется домен.

Aggregate: что менять атомарно, а что — нет

Когда контексты определены, внутри каждого нужно выделить агрегаты. Aggregate — это кластер объектов, изменения в котором проходят как единая транзакция. Через корень агрегата (Aggregate Root) идёт всё взаимодействие с внешним миром.

В контексте «Кредитные заявки» главный агрегат — LoanApplication. Посмотрим на его устройство детально:

100%
graph TD
  subgraph AGG_LOAN["Агрегат: Кредитная заявка"]
      direction TB
      ROOT["LoanApplication<br/><b>Aggregate Root</b><br/><i>applicationId, status,<br/>requestedAmount, term</i>"]
      BORROWER["Borrower<br/><i>fullName, passport,<br/>inn, phone</i>"]
      INCOME["Income<br/><i>monthlyIncome,<br/>employer, position,<br/>employmentType</i>"]
      COLLATERAL["Collateral<br/><i>type, estimatedValue,<br/>documents</i>"]
      CREDIT_HIST["CreditBureauReport<br/><i>bureauRequestId,<br/>score, delinquencies</i>"]

      ROOT --> BORROWER
      ROOT --> INCOME
      ROOT --> COLLATERAL
      ROOT --> CREDIT_HIST
  end

  EXT_CUST["Клиент<br/><i>другой агрегат</i>"]
  EXT_SCORING["Скоринг<br/><i>другой контекст</i>"]
  EXT_OFFICER["Кредитный офицер<br/><i>другой агрегат</i>"]

  EXT_CUST -->|"customerId"| ROOT
  ROOT -.->|"applicationId"| EXT_SCORING
  EXT_OFFICER -->|"officerId"| ROOT

  style ROOT fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style BORROWER fill:#7b68ee,stroke:#5a4db2,color:#fff
  style INCOME fill:#7b68ee,stroke:#5a4db2,color:#fff
  style COLLATERAL fill:#f0a500,stroke:#c88400,color:#fff
  style CREDIT_HIST fill:#f0a500,stroke:#c88400,color:#fff
  style EXT_CUST fill:#e0e0e0,stroke:#999,color:#333
  style EXT_SCORING fill:#e0e0e0,stroke:#999,color:#333
  style EXT_OFFICER fill:#e0e0e0,stroke:#999,color:#333

Синий блок — корень агрегата LoanApplication. Всё, что внутри — его дочерние сущности: заёмщик (фиолетовый), данные о доходах (фиолетовый), залог (жёлтый), отчёт из бюро кредитных историй (жёлтый). Серые блоки — внешние агрегаты и контексты. Связь с ними — через идентификаторы (customerId, officerId, applicationId) и через пунктирную линию (асинхронное взаимодействие).

Ключевой момент: нельзя изменить доход заёмщика напрямую, минуя LoanApplication. Код income.setMonthlyIncome(150000) — неправильно. Правильно: loanApplication.updateIncome(150000). Корень агрегата проверяет бизнес-правило (доход не может быть отрицательным, для беззалогового кредита — не ниже порога) и только потом обновляет данные.

Правила, с которыми агрегаты не превращаются в монстров

1. Агрегат — это единица консистентности, а не единица хранения. Если Borrower меняется независимо от LoanApplication (скажем, заёмщик исправил опечатку в номере паспорта отдельным запросом) — это два разных агрегата. Не надо пихать всё в одну «корзину» ради удобства.

2. Ссылки — только по идентификатору. Агрегат хранит customerId, а не объект Customer с 30 полями. Это даёт независимость загрузки и сохранения, а заодно не даёт соблазну обратиться к customer.address.street напрямую через агрегат заявки.

3. Одна транзакция — один агрегат. Если нужно изменить и LoanApplication, и LoanAgreement — это две транзакции с eventual consistency. Да, это сложнее, чем один BEGIN...COMMIT. Но это масштабируется. А один гигантский COMMIT на полбазы данных — нет.

4. Бизнес-правила живут внутри агрегата. «Кредит не может быть выдан без положительного решения андеррайтинга», «сумма займа не может превышать 5-кратный годовой доход» — эти проверки должны быть в коде агрегата, а не размазаны по сервисному слою.

Почему это работает вместе: сквозной сценарий

Соберём всё в один сценарий. Клиент подаёт заявку на потребительский кредит:

Шаг 1. Контекст «Кредитные заявки» создаёт агрегат LoanApplication со статусом DRAFT. Все поля проходят валидацию внутри агрегата: доход не ниже порога, паспортные данные заполнены, залог указан (если требуется).

Шаг 2. Агрегат отправляет applicationId в контекст «Скоринг» (через событие ApplicationSubmitted). Скоринговая модель — это другой bounded context. Она знает о заявке только applicationId и минимальный набор данных: возраст, доход, кредитную историю.

Шаг 3. Скоринговая модель возвращает ScoringResult в контекст «Андеррайтинг». Андеррайтинг — самостоятельный контекст со своими правилами: например, если скоринговый балл в «серой зоне» (550–650), заявка уходит на ручную проверку кредитному офицеру.

Шаг 4. Андеррайтинг формирует UnderwritingDecision и отправляет его обратно в агрегат LoanApplication. Статус заявки меняется: APPROVED или REJECTED. Заметьте: андеррайтинг не трогает поля заявки напрямую — он только возвращает решение через API агрегата.

Шаг 5. Для одобренной заявки контекст «Кредитный договор» формирует LoanAgreement с графиком платежей и ПСК. Это новый bounded context — он не знает деталей скоринга и андеррайтинга. Ему нужны только утверждённые параметры: сумма, ставка, срок.

Шаг 6. Контекст «Выдача» получает agreementId, связывается с АБС и платёжным шлюзом, переводит деньги на счёт клиента. Для контекста выдачи не существует понятия «скоринговый балл» или «андеррайтинг» — только договор и платёжное поручение.

Весь этот сценарий держится на трёх китах: единый язык не даёт запутаться в терминах, bounded contexts изолируют зоны ответственности, агрегаты защищают консистентность данных.

Типичные ошибки: что ломается, если DDD внедрить «как-нибудь»

Ошибка 1: Ubiquitous Language в виде PDF-файла на общем диске

«Мы внедрили DDD: вот документ на 40 страниц со всеми терминами». Через месяц документ устарел, через два — никто не помнит, где он лежит. Единый язык живёт в коде. Название класса в Java/C#/Python — это и есть глоссарий. Обновили термин — переименовали класс. А Confluence — только для справки.

Ошибка 2: «Скоринг и андеррайтинг — одно и то же, объединим»

Первое желание junior-архитектора: «а давайте скоринг и андеррайтинг в один контекст, они же рядом». Соблазнительно. Но скоринг — это математическая модель с ежемесячными перекалибровками. Андеррайтинг — это бизнес-правила, которые диктует кредитный комитет. Объединили — и теперь при каждом изменении скоринговой модели вы пересобираете и деплоите андеррайтинг. А он не менялся. Вы создали зависимость там, где её не должно быть.

Ошибка 3: Агрегат-гигант

«Пусть LoanApplication включает в себя всё: и Borrower, и LoanAgreement, и PaymentSchedule, и Disbursement — удобно же, один findById() и всё готово». Через полгода загрузка одной заявки подтягивает 40 таблиц и блокирует их на запись. При 100 заявках в минуту база данных говорит «спасибо, я устала». Держите агрегаты маленькими.

Ошибка 4: Забыть, что DDD — только для Core Domain

Кредитный конвейер — Core Domain банка. Здесь DDD оправдан. Но админка для управления справочником отделений? Или внутренний портал для сотрудников? Там CRUD достаточно. Не надо внедрять bounded contexts в лендинг-страницу. Это не серебряная пуля.

Когда DDD окупается, а когда — нет

КритерийDDD нуженDDD избыточен
Сложность доменаМного правил, исключений, регуляторных требованийПростой CRUD
Размер команды4+ человек, разные роли1–2 разработчика
Частота измененийДомен меняется постоянно (законы, рынок)Стабильные требования на годы
Срок жизни проектаГоды, десятки релизовПрототип, MVP на пару месяцев
Цена ошибкиВысокая: штрафы, репутация, деньги клиентовНизкая: можно переписать

Банковский кредитный конвейер по всем пунктам попадает в левую колонку. Цена ошибки — реальные деньги клиентов (и, что немаловажно, штрафы от ЦБ). Команда — десятки человек. Требования меняются раз в квартал, а то и чаще. Это классический Core Domain, для которого DDD создавался.

Заключение

DDD — это не про «надо рисовать bounded contexts для всего». Это про то, чтобы сложный домен не превратился в запутанный код, который никто не решается трогать.

Три концепции, которые мы разобрали, работают как слои: единый язык — фундамент, без которого все договорённости рушатся. Bounded contexts — стены, которые не дают изменениям в одной части системы ломать другую. Агрегаты — замки на дверях, через которые данные не меняются в обход правил.

В банковском кредитном конвейере это выглядит так: пять контекстов (заявки, скоринг, андеррайтинг, договор, выдача) живут своей жизнью, агрегат LoanApplication держит консистентность заявки, а единый язык не даёт кредитному менеджеру и разработчику говорить на разных языках.

Начните с первого шага: договоритесь с командой о десяти ключевых терминах вашего домена. И запишите их прямо в коде, именами классов. Если через месяц эти имена всё ещё актуальны и никто не создал класс с названием DataProcessor — вы на верном пути.

P.S. Если после прочтения вы подумали «это всё очевидно» — отлично. Значит, вы уже интуитивно применяете DDD, просто не называли это так. А если подумали «у нас в проекте всё наоборот» — что ж, теперь вы знаете, с чего начать рефакторинг. И нет, «потом перепишем» — не вариант.