Уровень 4 · Персистентность Глава 15 9 мин

Anemic vs Rich Domain Model

Fowler о анемичной модели. Когда rich model нужна, когда достаточно process-oriented. Как эволюционировать от anemic к rich.

TL;DR

  • Anemic Domain Model — данные без поведения. Fowler назвал антипаттерном в 2003.
  • Rich Domain — инварианты внутри aggregate. Никаких публичных сеттеров, только методы бизнес-операций.
  • Anemic оправдан для сервисов-оркестраторов и простого CRUD без правил.
  • Переход anemic → rich — постепенный. Один инвариант, один метод за коммит.

Что такое Anemic Domain Model

The fundamental horror of this anti-pattern is that it’s so contrary to the basic idea of object-oriented design; which is to combine data and process together.

Martin Fowler AnemicDomainModel, 2003

Классический пример:

@dataclass
class Order:
    id: int
    customer_id: int
    total: Decimal
    status: str          # !! просто строка, никаких инвариантов          
    lines: list

class OrderService:
    def confirm_order(self, order_id: int) -> None:                       
        order = self.repo.find(order_id)
        if order.status != 'pending':
            raise ValueError('cannot confirm')
        order.total = sum(l.total for l in order.lines)
        order.status = 'confirmed'
        self.repo.save(order)

    def cancel_order(self, order_id: int, reason: str) -> None:           
        order = self.repo.find(order_id)
        if order.status == 'cancelled':
            raise ValueError('already cancelled')
        order.status = 'cancelled'
        order.cancel_reason = reason
        self.repo.save(order)

Всё логично на первый взгляд:

  • Order — данные.
  • OrderService — процедуры над данными.

Но это процедурный код в OOP-обёртке. OrderService мог бы быть модулем с функциями — эффект тот же. Никакого OOP-encapsulation.

Почему это антипаттерн (по Fowler)

    Инварианты не защищены. order.status = 'cancelled' можно установить откуда угодно, без проверки. Через полгода найдётся код, который делает это без соблюдения инвариантов.

    Логика размазана. Три разных use case проверяют «нельзя дважды подтвердить». Один из них забыл — баг.

    Тесты требуют настраивать инфраструктуру. Проверить бизнес-правило «нельзя подтвердить cancelled» → создать OrderService с моками repository, event bus.

    Нарушение SRP. OrderService растёт при добавлении новых операций. К году там 30 методов.

Rich Domain Model

Тот же пример rich-моделью:

class OrderStatus(Enum):
    PENDING = 'pending'
    CONFIRMED = 'confirmed'
    CANCELLED = 'cancelled'

@dataclass
class Order:
    id: OrderId
    customer_id: CustomerId
    _lines: list[OrderLine] = field(default_factory=list)
    status: OrderStatus = OrderStatus.PENDING
    cancel_reason: str | None = None
    _events: list[DomainEvent] = field(default_factory=list)

    @property
    def total(self) -> Money:
        return sum((line.total for line in self._lines), start=Money.zero('RUB'))

    def add_line(self, product_id: ProductId, quantity: int, unit_price: Money) -> None:
        if self.status is not OrderStatus.PENDING:
            raise DomainError('cannot modify non-pending order')
        if quantity <= 0:
            raise DomainError('quantity must be positive')
        self._lines.append(OrderLine(product_id, quantity, unit_price))

    def confirm(self) -> None:
        if self.status is not OrderStatus.PENDING:
            raise DomainError(f'cannot confirm order in state {self.status}')
        if not self._lines:
            raise DomainError('cannot confirm empty order')
        self.status = OrderStatus.CONFIRMED
        self._events.append(OrderConfirmed(order_id=self.id))

    def cancel(self, reason: str) -> None:
        if self.status is OrderStatus.CANCELLED:
            raise DomainError('already cancelled')
        if not reason:
            raise DomainError('cancel reason is required')
        self.status = OrderStatus.CANCELLED
        self.cancel_reason = reason
        self._events.append(OrderCancelled(order_id=self.id, reason=reason))

    def pull_events(self) -> list[DomainEvent]:
        events, self._events = self._events, []
        return events

Что здесь:

  • Setter’ов нет. status меняется только через confirm/cancel.
  • Инварианты локальны. Правило «нельзя подтвердить cancelled» — внутри confirm(). Никакой use case не может обойти.
  • Тесты — unit. Order.confirm() тестируется без БД, без моков.
  • События собираются. Rich model — не только data + methods, но и integration с event-driven.

Границы Rich Domain

Rich Model — не «каждая переменная должна быть private». Это инварианты защищены. Всё остальное — детали.

Когда Anemic оправдан

Anemic — не всегда антипаттерн. Fowler критикует его как «default choice для сложного домена». Но есть контексты, где он честнее:

    Сервис-оркестратор. Микросервис-«клей», который получает событие, вызывает 2-3 API, сохраняет, публикует. Собственных инвариантов нет. Всё, что делает — координация. Rich model была бы декорацией.

    ETL / data pipeline. Читаем из источника, трансформируем, пишем в целевой store. Данные — «сырые». Rich model добавила бы церемонию без пользы.

    Простой CRUD. Форма пользователя, сохранение, отображение. Никаких правил. Rich model — overengineering.

    Прототип, MVP. Скорость важнее чистоты. Через 3 месяца, когда появятся инварианты, — можно рефакторить.

Rich не значит DDD

Rich Model — техника OOP, введена Booch/Meyer до DDD. Можно писать rich model без всякого DDD, если домен не сложный.

DDD добавляет:

  • Ubiquitous Language,
  • Aggregates и Aggregate Roots,
  • Domain Services,
  • Domain Events,
  • Bounded Contexts.

Всё это надстройка. Rich model — фундамент.

Постепенный переход anemic → rich

Не надо переписывать всё сразу. Пошаговый подход:

    Найдите один инвариант. «Нельзя подтвердить cancelled» — начните с него.

    Добавьте метод в domain-класс. Order.confirm(), с проверкой инварианта.

    Замените setter на метод в use case. Было order.status = 'confirmed'; save(). Стало order.confirm(); save().

    Уберите setter (сделайте private). Через год — забудете, что раньше было по-другому.

    Повторите для следующего инварианта.

Rich model — не архитектурная революция. Это последовательность мелких refactoring’ов.

Как правильно: смена статуса через методы

# Плохо: setter напрямую
order.status = 'confirmed'
await repo.save(order)

# Хорошо: метод aggregate
order.confirm()
await repo.save(order)

Плюс:

  • Инвариант проверен.
  • События (если нужно) сгенерированы.
  • Логика централизована.

Как не надо

1. Setter’ы для всего

@dataclass
class Order:
    id: int
    status: str
    total: Decimal
    customer_id: int
    lines: list
    # никаких методов, только dataclass с полями                          

# использование:
order = repo.find(1)
order.status = 'confirmed'                # !! обход инвариантов          
order.total = new_total

Ничего не защищено. Это namedtuple с изменяемостью — не Domain Model.

2. Логика в Service, данные в Model — навсегда

Если у сервиса растёт число методов «работать с order» и он превращается в 500 строк — сигнал, что модель должна быть rich. Не игнорируйте.

3. Domain Model знает про infrastructure

from app.infrastructure.db.session import get_session      # !!  

class Order:
    def confirm(self):
        session = get_session()                              # !!  
        session.add(self)
        session.commit()

Rich не значит «содержит persistence». Rich Model не знает про БД, публикацию событий, HTTP. Инкапсулирует бизнес-правила и состояние.

4. God Aggregate

class Order:
    def confirm(self): ...
    def cancel(self): ...
    def refund(self, amount): ...
    def ship(self, address): ...
    def track(self): ...
    def calculate_shipping(self): ...
    def apply_promotion(self, code): ...
    def notify_customer(self): ...              # !! отправка email в aggregate  
    def update_analytics(self): ...             # !! аналитика в aggregate         

Aggregate ≠ «класс, куда сваливаем всё, что касается заказа». notify_customer — не задача Order. Это side effect на OrderConfirmed — обработчик события.

Правильно: aggregate содержит только бизнес-инварианты. Все остальные реакции — event handlers.

Trade-offs

СитуацияRich Model оправданAnemic + Service
Много инвариантов, сложные правила
DDD с aggregate и domain events
Долгоживущий сервис с богатым доменом
Сервис-оркестратор без правил
ETL / трансформация данных
Простой CRUD
Прототип на 2 недели

В твоём же коде

Анонимизированный микросервис — sitель без domain-модели. Order (для нас) — просто Pydantic-message из RabbitMQ. Всё поведение — в use case. Классический anemic-подход.

Оправдан ли? В текущем виде — да:

  • Сервис — оркестратор.
  • Единственный «инвариант» — «характеристика без имени/значения пропускается» — техническая фильтрация, не domain rule.
  • Никаких сложных состояний.

Когда пора вводить rich model:

  • Появится правило «нельзя стандартизировать позицию, если у неё уже есть normalized_data не старше 7 дней» — это доменный инвариант.
  • Появится NormalizationBatch aggregate с инвариантами количества/размера.
  • Появятся операции retry с состоянием.

Пока — anemic честнее.

Дальнейшее чтение

  • Martin Fowler. AnemicDomainModel. 2003. Оригинальный пост.
  • Eric Evans. Domain-Driven Design. Part II — почему rich model критична для сложного домена.
  • Vaughn Vernon. Implementing Domain-Driven Design. Chapter 6-7 — Entity, VO как rich objects.
  • Vladimir Khorikov. Unit Testing. Chapter 4 — как rich model упрощает тестирование.
  • Scott Wlaschin. Domain Modeling Made Functional. Rich model через типы в функциональном стиле.

Проверьте себя

Мини-quiz · закрепить

Проверьте себя

  1. Q1. Anemic Domain Model — это...

  2. Q2. Тест: rich ли моя model?

  3. Q3. Когда Anemic Domain Model оправдана?

  4. Q4. Как правильно менять статус в rich model?

  5. Q5. Что НЕ должно быть в aggregate?