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

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

Разработчику Архитектору

Две связанные темы сходятся в правиле 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?

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


См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").