Перейти к основному содержимому

Типы классов в DDD

Разработчику Архитектору Аналитику
Теория данных (раздел 3)

В доменной модели описаны смысл и правила предметной области. В классификации типов классов в ООП — общие архетипы (Controller, Repository, DTO). DDD (Domain-Driven Design) задаёт отдельный набор типов классов доменного слоя: объекты с идентичностью, объекты-значения, агрегаты, сервисы и события. Их имена и границы должны совпадать с общим языком (Ubiquitous Language), которым говорят разработчики и эксперты предметной области.

Когда читать эту главу

После Доменной модели и до углубления в Паттерны проектирования (таблица DDD-паттернов).

Для стратегического DDD (bounded context, контекстные карты) см. ту же главу про домен и раздел про декомпозицию в Стратегии декомпозиции монолитных систем.


Два слоя — домен и приложение

Путаница чаще всего возникает между доменным и прикладным (application) слоями.

СлойКто координируетПримеры классов
ДоменПравила предметной областиOrder, Money, OrderPlaced, TransferService (доменный)
ПрикладнойСценарии использования, транзакции, вызов инфраструктурыPlaceOrderHandler, PlaceOrderCommand, оркестрация репозиториев
ИнфраструктураБД, HTTP, очередиOrderRepository (реализация), SmtpEmailSender
ПредставлениеВвод-выводOrdersController, OrderDto

Доменный сервис выражает операцию на языке бизнеса (TransferMoney, CalculateShipping). Сервис приложения (use case) загружает агрегаты, вызывает домен, сохраняет изменения и публикует интеграционные события — без дублирования бизнес-правил.


Карта типов классов в DDD

ТипИдентичностьИзменяемостьТипичное имя в кодеСлой
Entity (сущность)Есть стабильный IDОбычно изменяемаяCustomer, OrderДомен
Value Object (объект-значение)По полямНеизменяемыйMoney, Address, EmailДомен
Aggregate / Aggregate RootКорень с IDГраница согласованностиOrder (корень), OrderLine (внутри)Домен
Domain ServiceНет своего состоянияPricingService, FraudCheckДомен (интерфейс)
Domain EventСобытие с меткой времениНеизменяемый фактOrderPlaced, PaymentFailedДомен
RepositoryIOrderRepositoryДомен (контракт), инфраструктура (реализация)
FactoryOrderFactoryДомен
SpecificationПравило как объектActivePremiumCustomerSpecДомен

Ниже — каждый тип подробнее.


Entity (сущность)

Сущность — объект, который определяется устойчивым идентификатором, а не текущим набором полей. Пользователь с id = 42 остаётся тем же пользователем после смены email.

Признаки в коде:

  • поле Id (или value object CustomerId);
  • методы, меняющие состояние с проверкой инвариантов (ConfirmEmail(), Block());
  • равенство по ID: два экземпляра с одним ID считаются одной сущностью.

Код ITЗагрузка примера кода…

Сущность может быть корнем агрегата или внутренним элементом агрегата (см. ниже).


Value Object (объект-значение)

Объект-значение описывает характеристику без собственной жизни в системе — деньги, адрес, диапазон дат, координаты. Два значения с одинаковыми полями взаимозаменяемы.

Практика:

  • неизменяемость (record, readonly struct, финальные поля);
  • валидация в конструкторе (Amount > 0, формат email);
  • равенство по всем значимым полям;
  • отсутствие суррогатного ID в БД (часто встраивается в таблицу сущности как колонки).

Код ITЗагрузка примера кода…

Значения удобно передавать между слоями внутри домена; наружу (в API) чаще отдают примитивы или DTO, собранные из value objects.


Aggregate и Aggregate Root (агрегат и корень)

Агрегат — кластер сущностей и значений, который изменяется как единое целое в одной транзакции. Корень агрегата (Aggregate Root) — единственная сущность, на которую ссылаются извне.

Правила:

  • внешний код вызывает только методы корня (order.AddLine(...), а не order.Lines[0].Quantity = 5);
  • инварианты проверяются внутри границы агрегата;
  • репозиторий загружает и сохраняет агрегат целиком, а не отдельные строки без корня;
  • ссылки между агрегатами — по ID, а не по прямым объектным ссылкам (избегают "большого комка грязи").

Пример границы: Order (корень) + коллекция OrderLine + возможно DeliveryAddress как value object. Отдельный агрегат Product на складе — другая граница; заказ хранит ProductId, а не живой объект Product.

Размер агрегата держат небольшим: один бизнес-инвариант на транзакцию. Слишком крупный агрегат ведёт к блокировкам и конфликтам при конкурентной записи.


Domain Service (доменный сервис)

Доменный сервис — операция предметной области, которую неудобно отнести к одной сущности:

  • затрагивает несколько агрегатов (TransferBetweenAccounts);
  • зависит от внешнего правила, но формулируется на языке домена (ShippingCostCalculator с тарифами как входными данными);
  • чистая политика без состояния (UniqueUsernamePolicy — интерфейс в домене, реализация с запросом к БД в инфраструктуре).

Доменный сервис не знает про HTTP, EF Core, Kafka. Он принимает и возвращает доменные типы.


Domain Event (доменное событие)

Доменное событие фиксирует факт, который уже произошёл в предметной области: OrderPlaced, InvoiceIssued. Имя — в прошедшем времени. Событие неизменяемо и содержит данные, нужные подписчикам (ID агрегата, сумма, метка времени).

Типичный поток:

  1. метод корня агрегата меняет состояние и добавляет событие в коллекцию DomainEvents;
  2. слой приложения после успешного Save публикует события в шину или вызывает обработчики;
  3. побочные эффекты (email, аналитика) живут в обработчиках, а не внутри сущности.

События помогают развязать подсистемы и строить event sourcing там, где нужен полный аудит изменений.


Repository (репозиторий)

Репозиторий в DDD — абстракция коллекции агрегатов в доменном слое — GetById, Add, иногда Find по спецификации. Методы называют по-русски/английски в терминах домена: FindOpenOrdersByCustomer, а не GetAllFromTableOrders.

  • интерфейс IOrderRepository объявляют в домене;
  • реализацию с ORM, SQL, кэшем помещают в инфраструктуру;
  • репозиторий возвращает полный агрегат, готовый к вызову доменных методов.

Репозиторий не содержит бизнес-правил ("можно ли отменить заказ" — в Order.Cancel()).


Factory (фабрика)

Фабрика инкапсулирует сложное создание агрегата, когда конструктор раздувается или нужны проверки на нескольких источниках:

  • OrderFactory.CreateFromCart(cart, customerId);
  • сборка агрегата из нескольких value objects с согласованностью.

Простые сущности создают через конструктор или статический метод Create на самой сущности; фабрика — для нетривиальных правил инициализации.


Specification (спецификация)

Спецификация выносит правило отбора или допустимости в отдельный объект: IsEligibleForDiscount, комбинируется через And / Or / Not. Удобно для единообразных фильтров в домене и для повторного использования в тестах.

В проектах с EF Core спецификацию иногда мапят на IQueryable; важно, чтобы смысл правила оставался в домене, а детали SQL — в инфраструктуре.


Что в DDD обычно не является доменным типом

КлассРольПочему не домен
DTOПередача по сетиНет поведения и инвариантов
Controller / EndpointHTTP, валидация форматаСлой представления
ViewModelЭкранUI
DbContext / Entity Framework сущность с атрибутами [Table]ПерсистентностьТехническая модель (допустима отдельно от домена)
Application Service / HandlerСценарийОркестрация, транзакции
Integration EventКонтракт между сервисамиМожет отличаться от доменного события по полям и версии

Допустим маппинг между доменной моделью и persistence-моделью (OrderOrderEntity) — это разделение слоёв, а не дублирование логики.


Мини-практикум по классификации классов

Ниже короткие учебные вопросы для самостоятельной проверки:

  1. Price с полями Amount и Currency без ID — это Entity или Value Object?
  2. Order с коллекцией OrderLine, где внешние вызовы идут только через Order — это агрегат или просто сущность?
  3. CalculateDeliveryCost использует адрес, вес и тарифы, но не хранит состояние — это Domain Service или Application Service?
  4. OrderPlaced с OrderId и OccurredAt — это Domain Event или DTO?

Рекомендуемый порядок ответа:

  • сначала определить границу ответственности;
  • затем идентичность и жизненный цикл;
  • затем место класса в слоях архитектуры.

Если классификация даётся с трудом, возвращайтесь к Доменной модели и Построению систем на основе классов и объектов.


Связь с "анемичной моделью"

Анемичная модель — классы только с get/set и вся логика в "сервисах". Для DDD это потеря главного преимущества — инварианты размазаны, состояние можно испортить, минуя домен.

Здоровая модель: поведение рядом с данными, которые оно защищает. Сервис приложения тонкий: загрузил агрегат → вызвал order.Place() → сохранил.


Чек-лист при проектировании класса

  1. Это факт с ID и жизненным циклом? → Entity (возможно, корень агрегата).
  2. Это описание без собственной идентичности? → Value Object.
  3. Изменение должно быть атомарным с соседними объектами? → Включить в один агрегат, наружу — только корень.
  4. Операция не принадлежит одной сущности? → Domain Service.
  5. Нужно сообщить о свершившемся факте? → Domain Event.
  6. Нужно достать или сохранить агрегат? → Repository (интерфейс в домене).
  7. Создание сложное? → Factory или фабричный метод на корне.

Как применять карту типов DDD в ежедневной разработке

Полезно воспринимать эту главу как таблицу маршрутизации для новых классов. Перед созданием файла задайте один вопрос: какую проблему решает будущий класс в предметной области.

Быстрый маршрут:

  • Есть жизненный цикл и идентификатор — Entity.
  • Есть смысл только в значении — Value Object.
  • Требуется атомарная согласованность группы объектов — Aggregate.
  • Операция охватывает несколько агрегатов — Domain Service.
  • Нужна фиксация факта для реакций — Domain Event.

Практика ревью DDD-кода

На code review удобно проходить короткий чек:

  • Где у агрегата граница и корень.
  • Какие инварианты защищаются методами корня.
  • Нет ли утечки инфраструктуры в домен.
  • Интерфейсы репозиториев выражены на языке домена.
  • События называют свершившиеся факты в прошедшем времени.

Частые ловушки

  • Добавление сеттеров в Entity "для удобства ORM".
  • Размещение бизнес-правил в Handler при пустом домене.
  • Смешение Domain Event и Integration Event в одном контракте.
  • Слишком крупные агрегаты с высокой конкуренцией записей.

См. также