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.
Классический пример:
@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 дней» — это доменный инвариант.
- Появится
NormalizationBatchaggregate с инвариантами количества/размера. - Появятся операции 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 · закрепить
Проверьте себя
Q1. Anemic Domain Model — это...
Q2. Тест: rich ли моя model?
Q3. Когда Anemic Domain Model оправдана?
Q4. Как правильно менять статус в rich model?
Q5. Что НЕ должно быть в aggregate?