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

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

Разработчику Архитектору Аналитику

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

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

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


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

Путаница чаще всего возникает между доменным и прикладным (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 считаются одной сущностью.
public sealed class Customer
{
public CustomerId Id { get; }
public Email Email { get; private set; }
public CustomerStatus Status { get; private set; }

public void ConfirmEmail(Email confirmed)
{
if (Status != CustomerStatus.Pending)
throw new DomainException("Подтверждение доступно только для ожидающих аккаунтов.");
Email = confirmed;
Status = CustomerStatus.Active;
}
}

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


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

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

Практика:

  • неизменяемость (record, readonly struct, финальные поля);
  • валидация в конструкторе (Amount > 0, формат email);
  • равенство по всем значимым полям;
  • отсутствие суррогатного ID в БД (часто встраивается в таблицу сущности как колонки).
public readonly record struct Money(decimal Amount, string Currency)
{
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount));
if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException(nameof(currency));
Amount = amount;
Currency = currency;
}

public static Money operator +(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException("Складывать можно только одну валюту.");
return new Money(left.Amount + right.Amount, left.Currency);
}
}

Значения удобно передавать между слоями внутри домена; наружу (в 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) — это разделение слоёв, а не дублирование логики.


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

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

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


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

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

См. также


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).