6.11. Доменная модель
Доменная модель
Доменная модель — это концептуальная конструкция, которая отражает структуру и поведение реального мира в рамках программного обеспечения. Она формируется из глубокого понимания предметной области, её терминологии, правил и взаимодействий между элементами. Её цель — обеспечить точное, последовательное и устойчивое к изменениям воплощение бизнес-логики в коде, отделённое от инфраструктурных и пользовательских слоёв приложения.
Прежде чем перейти непосредственно к доменной модели, необходимо ввести и разграничить ряд базовых понятий, которые составляют её основу: объект, сущность, модель, домен, предметная область. Эти термины часто смешиваются в повседневной практике, однако их чёткое определение критически важно для построения корректной архитектуры и избежания ошибок проектирования.
Объект
Объект — это экземпляр класса в объектно-ориентированной парадигме, обладающий состоянием и поведением. Состояние выражается через внутренние данные (поля или свойства), поведение — через методы, оперирующие этим состоянием. Объект существует в памяти только во время выполнения программы и исчезает при её завершении или уничтожении экземпляра. Важно понимать, что не всякий объект несёт в себе смысловую нагрузку, связанную с предметной областью: объект может быть вспомогательной структурой (например, объект для временного хранения промежуточных вычислений), техническим артефактом (например, HTTP-запрос или поток ввода-вывода), либо полноценным носителем доменной семантики. Различие между этими ролями определяется контекстом использования — и именно в доменном контексте объект приобретает статус сущности или значения.
Сущность
Сущность — это особый вид объекта, который определяется не набором своих атрибутов, а наличием устойчивого, уникального идентификатора. Этот идентификатор сохраняется на протяжении всего жизненного цикла сущности и позволяет однозначно отличать её от других сущностей того же типа, даже если все остальные характеристики изменятся. Например, пользователь системы с идентификатором 7e8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c остаётся тем же пользователем, даже если он сменит имя, электронную почту, пароль или статус. Его суть как отдельной единицы в системе закреплена за идентификатором, а не за конкретными значениями полей.
Ключевая характеристика сущности — преемственность идентичности. Именно она выделяет сущность из других объектов и делает её естественной кандидаткой на отображение в структуру реляционной базы данных в виде записи с первичным ключом. Сущности, как правило, имеют сложное поведение: они хранят данные и инкапсулируют логику, связанную с изменением своего состояния, соблюдением правил и взаимодействием с другими сущностями. Важно подчеркнуть: сущность — это объект, обладающий ответственностью за свою целостность и поведение, где идентификатор — лишь признак, позволяющий управлять жизненным циклом.
Значение (Value Object)
Наряду со сущностями в доменной модели широко используются объекты-значения — структуры, которые не имеют собственной идентичности и определяются исключительно совокупностью своих атрибутов. Их смысл заключён в том, что они представляют, а не кем они являются. Если два объекта-значения имеют одинаковые атрибуты, то они считаются равными, независимо от того, созданы ли они в разных местах кода или в разное время.
Типичные примеры — деньги, адрес, временной интервал, географические координаты, цвет. Объект Money { Currency = "RUB", Amount = 1000 } эквивалентен другому объекту с теми же параметрами, даже если оба созданы независимо. В отличие от сущностей, объекты-значения обычно являются неизменяемыми (immutable): любое изменение требует создания нового экземпляра, что упрощает рассуждения о состоянии и исключает побочные эффекты. Такая семантика позволяет безопасно использовать значения в составе сущностей, передавать их по цепочке вызовов и применять в вычислениях без риска неожиданной модификации.
Модель
Модель — это абстракция, предназначенная для представления определённого аспекта реальности в упрощённой, но функционально адекватной форме. В контексте разработки программного обеспечения модель может выполнять разные роли в зависимости от архитектурного слоя: в представлении — это DTO или ViewModel, в контроллере — промежуточная структура для передачи данных, в бизнес-логике — доменная модель. Однако важно не смешивать понятие модели как общего термина с доменной моделью как специализированным инструментом.
Модель, используемая вне доменного контекста, зачастую представляет собой паспорт данных — набор полей без поведения и без гарантий согласованности. Такие модели легко сериализуются и удобны для транспорта, но не содержат знаний о правилах предметной области. В противоположность этому, доменная модель — это носитель поведения и инвариантов. Размывание этой границы (например, использование одной и той же классической «модели» и в API, и в бизнес-логике) приводит к утечке концепций, нарушению инкапсуляции и хрупкости системы при изменении требований.
Домен и предметная область
Домен — это область знаний, деятельности или практики, которой посвящено программное обеспечение. Термин происходит от английского domain, что буквально означает «сфера влияния» или «область компетенции». Домен не является абстрактной конструкцией — он существует независимо от программистов и определяется специалистами-предметниками: бухгалтерами, логистами, врачами, юристами, операторами производства.
Предметная область — синоним домена, однако в русскоязычной технической литературе это выражение чаще подчёркивает конкретную реализацию процессов внутри организации или системы. Например, «домен онлайн-торговли» — это общее понятие, включающее в себя заказы, оплату, доставку, возвраты, а «предметная область интернет-магазина «ГаджетОк»» — это конкретная реализация этих процессов с учётом внутренних правил, ограничений и терминологии компании. Именно с этой уровневой спецификой ведётся работа при анализе требований: выявляются ключевые понятия (участники, события, условия), строится общий язык (Ubiquitous Language), и на его основе формируется доменная модель.
Domain-Driven Design (DDD)
Domain-Driven Design — это методология проектирования, предложенная Эриком Эвансом в 2003 году, направленная на центрирование разработки вокруг предметной области и её глубокого понимания. DDD — это подход к мышлению, при котором сложность системы осознаётся и управляется через концепции домена.
Основная идея DDD заключается в том, что программное обеспечение должно быть интерпретацией реальных процессов в терминах, понятных как разработчикам, так и экспертам предметной области. Для достижения этой цели вводятся стратегические и тактические инструменты: контекстные границы (Bounded Context), общий язык (Ubiquitous Language), агрегаты, репозитории, фабрики, сервисы и события предметной области.
DDD особенно эффективен в ситуациях, когда бизнес-логика сложна, многогранна и подвержена изменениям. Он становится необходимым при построении систем, где точность отражения правил, согласованность данных и адаптивность к новым требованиям — критические факторы успеха.
Доменная модель как система поведения и ограничений
Доменная модель — это динамическая система, в которой объекты хранят данные и действуют в соответствии с правилами предметной области. Её ценность определяется не объёмом информации, которую она содержит, а её способностью гарантировать корректность поведения при любых допустимых взаимодействиях. Это достигается через три взаимосвязанных компонента: структуру, поведение и инварианты.
Структура задаётся типами доменных объектов — сущностями, значениями и агрегатами. Поведение воплощается в методах этих объектов, которые выражают действия, возможные в реальном мире: «подтвердить электронную почту», «добавить товар в заказ», «начислить проценты». Инварианты — это условия, которые должны соблюдаться в любой момент существования модели, независимо от последовательности вызовов. Они являются формализацией бизнес-правил и служат гарантией, что система никогда не перейдёт в недопустимое состояние.
Таким образом, доменная модель — это вычислительная среда, в которой каждая операция производится с учётом семантики предметной области и её ограничений. Любое нарушение инварианта сигнализирует о фундаментальном рассогласовании между кодом и доменом — что требует пересмотра модели.
Агрегаты и корни агрегатов
В реальных системах сущности редко существуют изолированно. Они образуют группы взаимосвязанных объектов, которые должны изменяться согласованно, чтобы сохранять целостность. Такие группы называются агрегатами.
Агрегат — это кластер связанных объектов (сущностей и значений), который рассматривается как единое целое с точки зрения изменения состояния и обеспечения инвариантов. Важнейшее свойство агрегата — ограничение доступа: внешние объекты могут взаимодействовать с агрегатом только через его корневую сущность (aggregate root). Все остальные элементы агрегата являются внутренними и не должны быть доступны напрямую извне. Это правило позволяет локализовать логику и избежать частичных, противоречивых обновлений.
Рассмотрим пример: заказ в интернет-магазине. Заказ (Order) — это сущность с уникальным идентификатором. Каждый заказ содержит список позиций (OrderLine), каждая из которых ссылается на товар (Product) и указывает количество. Позиции сами по себе могут иметь идентификатор, но их смысл существует только в контексте конкретного заказа. Изменение количества в позиции, добавление или удаление позиции — всё это должно происходить через методы самого заказа, например:
order.AddLine(product, quantity)
order.RemoveLine(lineId)
order.ChangeLineQuantity(lineId, newQuantity)
Эти методы не просто модифицируют коллекцию — они проверяют бизнес-ограничения: например, запрещают добавление позиции, если заказ уже оплачен; гарантируют, что общая сумма не выйдет за лимит; обеспечивают, что количество товара не станет отрицательным. Агрегат «Заказ» таким образом становится единицей транзакционной целостности: любое изменение внутри него либо полностью применяется, либо откатывается целиком.
Корневая сущность — это интерфейс агрегата для внешнего мира. Она отвечает за поддержание согласованности всей группы и за предоставление только тех данных, которые необходимы другим частям системы — без раскрытия внутренней структуры.
Доменные сервисы
Не вся логика предметной области может быть естественно встроена в сущности или агрегаты. Иногда поведение:
- требует участия нескольких агрегатов, но не принадлежит ни одному из них;
- зависит от внешних ресурсов (например, курсов валют, реестра доступности);
- выражает правило, которое представляет собой координацию.
В таких случаях применяется паттерн доменного сервиса (Domain Service). Это объект, реализующий конкретную операцию предметной области, но не обладающий собственным состоянием, кроме того, что требуется для выполнения вызова. Его сигнатура формулируется на языке предметной области, а реализация гарантирует соблюдение инвариантов на уровне взаимодействия.
Примеры:
OrderValidationService.Validate(Order order, Customer customer)— проверяет, разрешено ли оформление заказа с учётом лимитов клиента и состояния склада.PaymentCalculator.CalculateTotal(Order order, Address deliveryAddress)— вычисляет итоговую стоимость с учётом доставки, скидок и налогов.AccountTransferService.Transfer(Account from, Account to, Money amount)— выполняет перевод средств между счётами с блокировкой и атомарным обновлением балансов.
Ключевое отличие доменного сервиса от инфраструктурного — уровень абстракции. Доменный сервис оперирует понятиями предметной области (Account, Money, Order) и не знает о базе данных, HTTP-клиентах или очередях сообщений. Его интерфейс объявляется в доменном слое, а реализация может быть предоставлена инфраструктурным слоем через зависимости (например, через инверсию управления). Это обеспечивает тестируемость, заменяемость и защиту логики от технических деталей.
Инварианты и обеспечение целостности
Инвариант — это утверждение, которое должно быть истинным в любой момент жизни доменного объекта или агрегата. Он формулируется как бизнес-правило и реализуется как защитное условие в коде. Инварианты бывают трёх уровней:
-
Атрибутивные — касаются отдельных полей:
«Email не может быть пустым»,
«Сумма перевода должна быть положительной»,
«Дата рождения не может быть в будущем». -
Внутриобъектные — связывают несколько атрибутов одного объекта:
«Если заказ оплачен, его статус не может быть „черновик“»,
«У активного аккаунта должна быть подтверждённая электронная почта». -
Межобъектные (агрегатные) — охватывают несколько объектов внутри агрегата:
«Сумма всех позиций заказа не может превышать кредитный лимит клиента»,
«Количество товара на складе не может стать отрицательным после резервирования».
Инварианты должны проверяться в момент изменения состояния, а не откладываться до сохранения в базу. Это означает, что конструкторы и методы изменения сущностей обязаны валидировать входные данные и текущее состояние до применения изменений. Исключения, возникающие при нарушении инварианта, носят характер InvalidOperationException, ArgumentException или специализированных доменных исключений — они сигнализируют о попытке выполнить недопустимую в предметной области операцию, а не о технической ошибке.
Такой подход приводит к тому, что все экземпляры доменных объектов в памяти всегда находятся в валидном состоянии. Это устраняет необходимость в «защитном программировании» на каждом уровне вызова и делает код предсказуемым.
События предметной области (Domain Events)
Событие предметной области — это фиксация факта, произошедшего в системе и имеющего значение для бизнеса. В отличие от технических событий (например, «HTTP-запрос получен»), доменные события выражаются на языке предметной области и отражают изменение состояния, которое нельзя игнорировать:
UserRegistered,
OrderPlaced,
PaymentSucceeded,
ProductStockDepleted.
Каждое событие несёт только неопровержимые данные: кто, что, когда, при каких условиях. Оно не содержит логики и не управляет поведением — оно лишь констатирует. Обработка события (отправка письма, начисление бонусов, обновление аналитики) происходит в других частях системы — в обработчиках (handlers), которые могут быть как синхронными, так и асинхронными.
Использование событий позволяет:
- декомпозировать сложные операции, избегая «монолитных» методов;
- реализовать реактивное поведение без жёсткой связи между компонентами;
- строить историю изменений (event sourcing) и обеспечивать воспроизводимость;
- поддерживать согласованность в распределённых системах через eventual consistency.
События не обязательны в простых приложениях, но становятся мощным инструментом при росте сложности и необходимости интеграции с внешними системами.
Интеграция доменной модели в архитектуру приложения
Доменная модель не существует в вакууме. Её ценность проявляется только тогда, когда она органично встроена в общую архитектуру системы и защищена от вторжения технических деталей. Для этого применяются принципы слоистой архитектуры, в частности — разграничение на доменный слой, инфраструктурный слой, прикладной (application) слой и представление.
Доменный слой — это ядро приложения. Он содержит:
- сущности, значения, агрегаты;
- доменные сервисы и события;
- интерфейсы репозиториев и фабрик (не реализации!).
Важнейшее правило: доменный слой не должен зависеть ни от каких внешних библиотек, фреймворков или технологий. Он пишется на чистом языке, использует только стандартную библиотеку, и формулируется исключительно в терминах предметной области. Любая зависимость от ORM, HTTP-клиентов, баз данных или очередей — это нарушение границы и угроза долгосрочной поддерживаемости.
Прикладной слой (или уровень use-case) координирует взаимодействие между доменом и инфраструктурой. Он реализует сценарии использования: «оформить заказ», «подтвердить email», «провести инвентаризацию». Каждый сценарий:
- получает входные данные (часто в виде простых DTO);
- обращается к доменному слою для выполнения бизнес-операции;
- при необходимости использует инфраструктурные сервисы (репозитории, внешние API);
- возвращает результат — опять же, в виде нейтральных структур.
Именно здесь происходит управление транзакциями, обработка ошибок верхнего уровня, логирование и аудит. Но никакой бизнес-логики — только оркестрация.
Инфраструктурный слой реализует интерфейсы, объявленные в домене:
IUserRepository→PostgreSqlUserRepository;IEmailSender→SmtpEmailService;ICurrencyRateProvider→CbrApiAdapter.
Такой подход (известный как ports and adapters или hexagonal architecture) гарантирует, что смена базы данных, почтового провайдера или внешнего API не затронет ядро системы. Доменная модель остаётся неизменной, потому что она отражает устойчивые реалии предметной области, а не текущие технические решения.
Роль ORM, репозиториев и фабрик
Объектно-реляционные мапперы (ORM), такие как Entity Framework, Hibernate или SQLAlchemy, часто ошибочно воспринимаются как инструмент для построения доменной модели. На деле ORM — это инфраструктурная технология, предназначенная для персистентности, то есть сохранения и восстановления состояния доменных объектов. Нарушение этого разделения приводит к так называемым анемичным моделям — классам, которые выглядят как сущности, но содержат только свойства и не имеют поведения.
Репозиторий — это абстракция, предоставляющая коллекцию-подобный интерфейс для доступа к агрегатам. Его задача — изолировать доменную логику от знания о том, где и как хранятся данные. Важно:
- репозиторий оперирует агрегатами целиком, а не отдельными сущностями;
- методы репозитория формулируются как запросы предметной области:
FindOrdersByCustomer(customerId),GetActiveOrdersPlacedAfter(date); - репозиторий не должен возвращать «живые» прокси-объекты ORM, которые отслеживают изменения — это нарушает инкапсуляцию агрегата.
Фабрика (Factory) отвечает за создание сложных объектов или агрегатов, особенно когда процесс инициализации требует координации нескольких шагов или источников данных. В отличие от конструктора, фабрика может:
- применять бизнес-правила при построении объекта (например, проверять согласованность данных перед созданием заказа);
- взаимодействовать с внешними сервисами (например, запросить актуальный курс валют при создании международного платежа);
- скрывать детали построения, оставляя интерфейс создания простым и семантически выразительным:
OrderFactory.CreateFromCart(cart, customer, deliveryAddress).
Конструкторы же остаются ответственными за гарантированную валидность минимального состояния объекта — всё, что выходит за рамки, переносится в фабрики или методы-билдеры.
Распространённые ошибки и анти-паттерны
-
Анемичная модель — сущности содержат только свойства, а вся логика вынесена в отдельные сервисы. Это приводит к потере инкапсуляции: состояние и поведение разрываются, инварианты проверяются в разных местах, объекты могут существовать в недопустимых состояниях. Такая «модель» — не более чем схема данных.
-
Нарушение границ агрегата — прямой доступ к внутренним сущностям (например,
order.Lines[0].Quantity = -5). Это обходит защитные методы корня и делает невозможным контроль инвариантов. -
Смешение слоёв — использование атрибутов ORM (
[Key],[Required]) или HTTP-аннотаций в доменных классах. Это привязывает бизнес-логику к конкретной инфраструктуре и затрудняет тестирование. -
Использование доменных объектов в представлении — передача сущности напрямую в UI или сериализация в JSON. Это раскрывает внутреннюю структуру, нарушает принцип «не возвращать больше, чем нужно» и затрудняет эволюцию модели.
-
Отложенная валидация — проверка правил только при сохранении в БД (например, через ограничения
CHECKили триггеры), а не при каждом изменении состояния. Это создаёт временные окна, в которых система находится в неконсистентном состоянии. -
Глобальные сервисы в конструкторах — внедрение
ILogger,IHttpClientилиIDateTimeProviderнапрямую в сущность. Это нарушает SRP и делает объекты нетестируемыми. Зависимости от инфраструктуры должны появляться только на уровне сценариев или доменных сервисов.
Критерии зрелости доменной модели
Качественная доменная модель обладает следующими признаками:
- Язык предметной области прослеживается в именах классов, методов, параметров и исключений — код читается как описание бизнес-процесса.
- Все объекты в памяти всегда валидны — невозможны состояния, нарушающие инварианты.
- Поведение инкапсулировано — изменение данных возможно только через методы, выражающие семантику действия.
- Агрегаты соблюдают границы — внешние вызовы ограничены корневыми сущностями.
- Нет зависимости от фреймворков — доменный слой компилируется без ссылок на ORM, веб-стек или базы данных.
- Легко тестируется в изоляции — для проверки бизнес-логики не требуется запускать базу или HTTP-сервер.
- Гибкость к изменениям — модификация бизнес-правила требует изменений в одном месте, без каскадных правок по всей системе.
Сравнение с альтернативными подходами
В практике разработки встречаются другие стратегии организации логики, и важно понимать их отличия от доменной модели:
-
Транзакционные скрипты — логика реализуется в виде процедур, которые напрямую манипулируют данными (часто через Data Access Objects). Подходят для простых, линейных операций, но быстро становятся неуправляемыми при росте сложности. Отсутствует переиспользование, трудно поддерживать согласованность.
-
Active Record — каждый объект объединяет данные и методы доступа к БД (
user.Save(),user.Delete()). Удобен для прототипирования, но нарушает разделение ответственности и затрудняет тестирование и эволюцию схемы. -
Анемичные модели с сервисами — гибрид, при котором «сущности» являются DTO, а вся логика сосредоточена в сервисах. Такой подход маскируется под DDD, но лишён его главного преимущества — инкапсуляции поведения. Часто возникает дублирование: один и тот же инвариант проверяется в нескольких сервисах.
Доменная модель не является универсальным решением — она требует инвестиций в анализ, проектирование и дисциплину. Но при работе со сложными, изменяющимися бизнес-процессами она оказывается единственным способом построить систему, которая остаётся понятной, расширяемой и надёжной на протяжении многих лет.