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

Анемичные модели и примитивная одержимость

Разработчику Архитектору
Теория данных (раздел 3)

Структуры в коде — Структуры данных; 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, phoneEmail, PhoneВалидация в одном месте — см. Проверка и валидация
double lat/lonGeoCoordinateЕдиницы и диапазоны
string + string датыDateInterval, InstantНет "31.02" в трёх слоях
Map / JSON blobPermissions, 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

  1. Новый string/int в публичном API — имя понятия из домена есть?
  2. Логика в *Service — можно ли перенести на сущность без "божественного" сервиса?
  3. Два метода с пятью примитивами — нужен ли parameter object?
  4. Тест домена — без мока БД и HTTP?

Указатель тем — Справочник тем чистого кода. Дальше: ИзменяемостьДекларативный стильУсловия и null.