Анемичные модели и примитивная одержимость
Структуры в коде — Структуры данных; persistence — ORM.
Две связанные темы сходятся в правиле MAPPER — данные и поведение предметной области должны жить вместе, а примитивы (string, int, decimal) — только там, где в реальности нет отдельного понятия.
Анемичная модель
Анемичная модель — классы-"контейнеры" с полями, геттерами и сеттерами, без инвариантов и доменных операций. Вся логика уезжает в *Service, *Manager, *Helper.
// Анемично: инвариант «сумма строк = total» нигде не закреплён
public class Order {
public List<OrderLine> Lines { get; set; } = new();
public decimal Total { get; set; }
}
public class OrderService {
public void Recalculate(Order order) {
order.Total = order.Lines.Sum(l => l.Price * l.Qty);
}
}
// Богаче: объект сам отвечает за согласованность
public sealed class Order {
private readonly List<OrderLine> _lines = new();
public Money Total { get; private set; }
public void AddLine(OrderLine line) {
_lines.Add(line);
Total = Money.Sum(_lines.Select(l => l.Subtotal));
}
}
Почему это запах:
- инкапсуляция формально есть, смысловой границы нет;
- любое изменение тянет правки в нескольких сервисах (связанность);
- тесты вынуждены поднимать половину приложения.
См. типы классов в DDD, ООП — инкапсуляция.
Практики против анемичности
| Симптом | Направление |
|---|---|
| Генераторы CRUD/DTO | Убрать автогенерацию "анемичных" сущностей |
| Пустой конструктор + сеттеры | Задавать обязательные поля при создании |
| Публичные сеттеры "на всё" | Операции addLine, cancel, pay вместо setState |
| DTO на каждый слой | DTO только на границе, внутри — доменные типы |
| Оргия мелких объектов | Слить по смыслу, а не по таблицам БД |
Примитивная одержимость (Primitive Obsession)
Одержимость элементарными типами — когда вместо понятий домена везде string, int, bool. Фаулер называет это Primitive Obsession; лечение — реификация: введение маленького типа с правилами.
// Примитивы: легко перепутать аргументы
function sendReceipt(email: string, amount: number, currency: string) { ... }
// Реификация
class Email {
constructor(private readonly value: string) {
if (!value.includes("@")) throw new Error("Invalid email");
}
}
class Money {
constructor(readonly amount: number, readonly currency: string) {}
}
Типичные кандидаты в value object:
| Вместо | Объект | Зачем |
|---|---|---|
string email, phone | Email, Phone | Валидация в одном месте — см. Проверка и валидация |
double lat/lon | GeoCoordinate | Единицы и диапазоны |
string + string даты | DateInterval, Instant | Нет "31.02" в трёх слоях |
Map / JSON blob | Permissions, Tags | Явный API коллекции |
int статус | enum / state object | Исчерпывающие переходы |
Дополнительно: строки — не универсальный контейнер; ассоциативные массивы "на всякий случай" — тоже запах (лучше объект с именованными полями).
Value objects в DDD — доменная модель; пошаговая реификация — примитивы и маленькие типы; каталог приёмов — рефакторинг.
DTO и границы слоёв
DTO уместны на границе (HTTP, очередь, БД), когда контракт транспорта отделён от домена. Проблема начинается, когда DTO проникают внутрь и заменяют модель:
API DTO → Service копирует поля → ещё один DTO → Repository
Целевой поток:
API DTO → адаптер → доменный объект → use case
DTO только на границе — внутри домена остаются типы с поведением.
Связь с ORM и фреймворками
ORM и codegen по умолчанию толкают к анемичности (публичные свойства, сеттеры). Это не приговор: можно:
- маппить на закрытые конструкторы и фабрики;
- держать богатую модель в ядре, а persistence — в инфраструктуре (чистая архитектура).
Чек-лист перед merge
- Новый
string/intв публичном API — имя понятия из домена есть? - Логика в
*Service— можно ли перенести на сущность без "божественного" сервиса? - Два метода с пятью примитивами — нужен ли parameter object?
- Тест домена — без мока БД и HTTP?
Указатель тем — Справочник тем чистого кода. Дальше: Изменяемость → Декларативный стиль → Условия и null.