open atlas
↑ К треку
Архитектурные паттерны ARCH · 03 · 02

Anemic vs rich domain model

Анемичная доменная модель помещает всё поведение в сервисы, оставляя доменные объекты чистыми контейнерами данных. Это признанный антипаттерн для сложных доменов, уничтожающий инкапсуляцию. Для простого CRUD — нередко прагматичный выбор.

ARCH Middle ◷ 22 min
Уровень
ОсновыJuniorMiddleSenior

На B2B-платформе заказов был класс Order с двадцатью полями: id, status, customerId, lineItems, invoiceId, totalAmount, approvedBy, approvedAt, confirmedAt, cancelledAt и другие. У класса были геттеры и сеттеры для каждого поля — и ничего больше. Вся логика жила в OrderService. Там был метод confirmOrder, метод cancelOrder, метод approveOrder и метод applyDiscount. Через три месяца после запуска платформы в продакшене нашли баг: отменённый заказ был выставлен на счёт. Первопричина: confirmOrder и billing-задание оба изменяли поле status заказа напрямую — одно через setStatus('confirmed'), другое через ORM-обновление, — не зная друг о друге. Инвариант «отменённый заказ нельзя подтвердить или выставить на счёт» существовал в комментариях и в головах команды, но нигде в коде. Поскольку класс Order не владел никаким поведением, он не применял никаких правил. Любой код в системе мог установить любое поле в любое значение в любой момент. Модель была технически объектно-ориентированной — она использовала класс — но ничего реально не инкапсулировала.

Как выглядит anemic domain model

Мартин Фаулер ввёл термин «Anemic Domain Model» в 2003 году для описания паттерна, ставшего повсеместным: доменные объекты — не более чем именованные структуры данных (геттеры, сеттеры, никакого реального поведения) — в сочетании с классами-сервисами, содержащими всю логику.

// Anemic: Order — пакет данных
class Order {
  id: string;
  status: string;
  lineItems: LineItem[];
  totalAmount: number;
  customerId: string;
  approvedBy?: string;
  approvedAt?: Date;

  // Только геттеры и сеттеры — никакого поведения, никакого применения правил
  setStatus(s: string) { this.status = s; }
  setApprovedBy(userId: string) { this.approvedBy = userId; }
}

// Вся логика в сервисе
class OrderService {
  async confirmOrder(orderId: string, userId: string) {
    const order = await this.repo.findById(orderId);
    if (order.status === 'pending') {
      order.setStatus('confirmed');
      order.setApprovedBy(userId);
      order.approvedAt = new Date();
      await this.repo.save(order);
    }
  }
}

Сервис знает правила. Объект Order ничего не применяет. Любой код в системе может вызвать order.setStatus('confirmed') напрямую, обойдя все проверки в OrderService. Инвариант «только pending-заказы можно подтвердить» существует только внутри одного метода одного класса-сервиса.

Вердикт Фаулера: это антипаттерн, потому что он лишает смысла объектно-ориентированный дизайн. Объект, не владеющий никаким поведением и не применяющий никаких инвариантов — это не доменная модель, а struct из C с синтаксисом Java.

Почему это становится проблемой — при сложных инвариантах

Деградация anemic-модели появляется постепенно, и именно тогда, когда инварианты становятся сложными или множественными.

На B2B-платформе правила, управляющие жизненным циклом заказа, со временем умножились:

  • Pending-заказ можно подтвердить только если у него есть хотя бы одна позиция.
  • Заказ на сумму свыше $10 000 требует одобрения перед подтверждением.
  • Подтверждённый заказ можно отменить только в течение 24 часов.
  • Отменённый заказ нельзя выставить на счёт, подтвердить или переоткрыть.
  • Итоговая сумма заказа всегда должна равняться сумме его позиций.

Каждое из этих правил было реализовано внутри отдельного метода сервиса. confirmOrder проверял два из них. cancelOrder — одно. Billing-задание — ни одного: оно было написано другой командой, не знавшей о правилах жизненного цикла заказа. Баг был не ошибкой программирования — это была структурная ошибка. Правила были разбросаны по методам сервиса без гарантии быть единственным путём мутации заказа.

Инкапсуляция — это ключевое свойство, которое anemic-модель уничтожает. Инкапсуляция — это не сокрытие полей за геттером/сеттером: это просто синтаксическая обёртка. Настоящая инкапсуляция означает, что внутреннее состояние объекта может изменяться только через операции, применяющие инварианты объекта. Если любой внешний код может вызвать setStatus('confirmed') напрямую, объект Order не даёт никакой инкапсуляции, независимо от того, является ли поле status приватным.

Rich domain model как альтернатива

Rich domain model помещает поведение вместе с данными, которыми оно управляет. Объект Order владеет своими переходами состояний и применяет инварианты на границе каждой операции:

// Rich: Order владеет своими инвариантами
class Order {
  private status: OrderStatus;
  private lineItems: LineItem[];
  private totalAmount: Money;
  private approvalRecord?: ApprovalRecord;

  confirm(approvedBy: UserId): void {
    if (this.status !== OrderStatus.Pending) {
      throw new InvalidOrderTransitionError(
        `Нельзя подтвердить заказ в статусе ${this.status}`
      );
    }
    if (this.lineItems.length === 0) {
      throw new OrderInvariantViolation('Нельзя подтвердить заказ без позиций');
    }
    if (this.totalAmount.greaterThan(Money.of(10_000, 'USD')) && !this.approvalRecord) {
      throw new OrderInvariantViolation('Заказы свыше $10 000 требуют предварительного одобрения');
    }
    this.status = OrderStatus.Confirmed;
    this.confirmedAt = new Date();
  }

  cancel(): void {
    if (this.status === OrderStatus.Cancelled) {
      throw new InvalidOrderTransitionError('Заказ уже отменён');
    }
    this.status = OrderStatus.Cancelled;
    this.cancelledAt = new Date();
  }
}

Теперь BillingJob не может установить status = 'confirmed' напрямую — он должен вызвать order.confirm(), которая применяет каждое соответствующее правило. Баг из вступительной истории становится невозможным: вызов confirm() на отменённом заказе выбросит ошибку. Ни один второй вызывающий не может обойти это.

Почему это работает

Почему совмещение поведения с данными важнее, чем просто стилевое предпочтение? Потому что доменный объект становится единственной точкой истины о том, как выглядит валидное состояние. С anemic-моделью истина распределена по каждому методу каждого сервиса, когда-либо трогавшего объект — и по каждому разработчику, который когда-либо напишет новый метод. С rich-моделью истина в одном месте. Можно прочитать класс Order и знать каждое правило перехода состояния без чтения остального кода. Это свойство важнее всего при сложном домене и большой команде.

Честный контраргумент: когда anemic подходит

Критика Фаулера верна для сложных доменов со значимыми инвариантами. Это НЕ универсальный закон. Anemic-модель вполне уместна когда:

Домен — действительно CRUD. Сервис управления профилями пользователей — создать, прочитать, обновить, удалить записи — не имеет state machine, многоэтапного жизненного цикла, инвариантов сложнее «email должен быть уникальным». Rich domain model здесь добавит косвенность и boilerplate без отдачи. Anemic-модель честна в том, что собой представляет домен.

Домен прост и стабилен. Таблицы конфигурации, справочники, референсные данные — это управление данными, а не доменное моделирование. ORM-сущности с геттерами и сеттерами — правильный инструмент.

Приложение краткосрочное или одноразовое. Скрипт миграции данных, одноразовый ETL-job, внутренняя admin-панель для трёх пользователей — стоимость создания rich domain model не оправдана отдачей.

Метка антипаттерна применяется, когда команда использует anemic-модель для сложного домена со значимыми инвариантами жизненного цикла — и затем испытывает последствия (нарушения инвариантов, разбросанная логика, невозможность рассуждать о состоянии), настаивая, что сделала правильный выбор. Проблема — не религиозный вопрос anemic vs rich. Проблема — использование неправильного инструмента для реальной сложности домена.

lesson.inset.note

Transaction Script (тоже термин Фаулера) — это anemic-подход, доведённый до логического завершения: процедура, выполняющая серию шагов для завершения бизнес-транзакции, без персистентных доменных объектов вообще. Transaction Script хорошо работает для простых воркфлоу, где процедурная ясность перевешивает потерю инкапсуляции. Становится проблемой, когда процедуры разрастаются, пересекаются в мутациях состояния и начинают дублировать одни и те же бизнес-правила в разных местах. Процедурный подход масштабируется с простотой; rich domain model масштабируется со сложностью инвариантов.

Викторина

На B2B-платформе есть инвариант: подтверждённый заказ можно отменить только в течение 24 часов с момента подтверждения. Это правило проверяется в `OrderService.cancelOrder()`. Новое фоновое задание читает заказы и вызывает `order.setStatus('cancelled')` напрямую для очистки устаревших заказов. Какое структурное свойство anemic-модели позволило появиться этому багу?

Викторина

Команда строит billing-модуль B2B-платформы. Модуль создаёт счета, хранит записи платежей и связывает их с заказами. Нет сложных переходов состояний — счёт создаётся один раз, помечается оплаченным один раз и больше не меняет состояния. Стоит ли использовать rich или anemic-модель для Invoice?

Викторина

При рефакторинге anemic-объекта Order в rich domain model команда обнаруживает, что нельзя просто добавить методы вроде `confirm()` в класс Order — существующий код вызывает `order.setStatus()` в 47 разных местах. Что говорит это распределение и что оно означает для рефакторинга?

Вспомните перед уходом
  1. 01
    Что делает anemic domain model антипаттерном и какое свойство домена определяет, является ли это реальной проблемой?
  2. 02
    Что означает настоящая инкапсуляция в контексте доменного объекта и как rich domain model её обеспечивает?
  3. 03
    Когда anemic domain model — подходящий выбор и как понять, что домен из неё вырос?
Итог

Anemic domain model — естественный результат построения слоистой архитектуры с последующим выхолащиванием domain layer. Доменные объекты становятся пакетами данных — поля, геттеры, сеттеры — а сервисы несут всю логику. Внешне это объектно-ориентированный дизайн, потому что используются классы, но ни одного из преимуществ инкапсуляции, ради которых существует ООП, он не даёт.

Критика Фаулера: anemic-модель — антипаттерн для сложных доменов, потому что разделяет поведение и данные, которые оно должно защищать. Любой вызывающий может обойти сервис с правилами и мутировать состояние доменного объекта напрямую. Инварианты дрейфуют в неявные соглашения, поддерживаемые ревью и командной дисциплиной, а не структурным принуждением. Когда домен усложняется, а количество вызывающих растёт — эти соглашения ломаются.

Rich domain model совмещает поведение с данными. Order.confirm() несёт полную логику инварианта перехода подтверждения. Ни один вызывающий не может обойти её. API доменного объекта — набор валидных операций, каждая из которых применяет свои предусловия.

Честный контраргумент: для по-настоящему простых CRUD-доменов — двухсостоянные сущности, референсные данные, краткосрочные скрипты — anemic-модель прагматична и уместна. Boilerplate и косвенность rich-модели стоят дороже, чем дают. Решение должно приниматься осознанно, исходя из реальной сложности инвариантов домена, а не как рефлекторный выбор паттерна.

Следующий урок разбирает, что происходит, когда слоистость применяется, но дисциплина не выдерживается: transaction-script trap, layer-skipping и fat service, который становится god object приложения.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.