Новое в журнале. Маленькие pull request: почему их быстрее ревьюят и как резать задачу
Принципы и архитектура

Анемичная доменная модель: почему сущности без поведения мстят позже

Классы-контейнеры с геттерами и сеттерами выглядят безопасно, но переносят всю логику в сервисы и дублируют правила по всему приложению. Разбираем, когда это нормально, а когда вредит.

2 декабря 20259 мин чтенияПрактикам
Анемичная доменная модель: почему сущности без поведения мстят позже
2 слояданные и логика врозь
1 признакпубличные сеттеры без правил

Анемичная доменная модель, «anemic domain model», это код, где классы домена состоят почти целиком из полей и пар геттер/сеттер, а вся логика живёт в отдельных сервисах. Формально это объекты, по факту это структуры данных в костюме класса. Мартин Фаулер назвал такую конструкцию антипаттерном ещё в начале двухтысячных, но она продолжает появляться в новых проектах, потому что выглядит простой на старте.

Причина популярности понятна: класс с одними геттерами и сеттерами пишется быстро, не требует продумывания инвариантов и легко сериализуется в JSON без лишнего кода. На короткой дистанции это действительно экономит время. Проблема проявляется позже, когда правил становится больше одного и каждое новое правило нужно где-то разместить, а очевидного места внутри анемичного класса для него не предусмотрено.

Как это выглядит на практике

Типичный пример: класс Order с полями статуса, суммы и списка товаров, у которого нет ни одного метода, кроме геттеров и сеттеров. Вся логика смены статуса, пересчёта суммы и проверки допустимости действия находится в сервисе OrderService, который дёргает поля заказа напрямую.

Такой стиль часто появляется вместе с ORM, который из коробки генерирует сущности с одними свойствами и ожидает, что разработчик допишет поведение сам. Проблема не в самом ORM, а в привычке остановиться на сгенерированном каркасе и не добавить ни одного метода сверху, оставив всю логику в сервисном слое по умолчанию.

final class Order
{
    private string $status = 'new';
    private float $total = 0.0;

    public function getStatus(): string
    {
        return $this->status;
    }

    public function setStatus(string $status): void
    {
        $this->status = $status;
    }

    public function getTotal(): float
    {
        return $this->total;
    }

    public function setTotal(float $total): void
    {
        $this->total = $total;
    }
}

Снаружи этот класс выглядит нейтрально: обычные поля, обычные методы доступа. Проблема начинается, когда правило «заказ можно отменить только из статуса new или paid» появляется не в самом классе Order, а в сервисе, который его обрабатывает. Если отмена заказа инициируется из трёх разных мест приложения, админки, API и консольной команды, правило придётся продублировать в каждом из них или аккуратно протащить через общий сервис, не ошибившись ни в одном вызове.

Главная мысльАнемичная модель не нарушает синтаксис объектно-ориентированного программирования, она нарушает его смысл: данные есть, поведения нет.

Почему это считается антипаттерном

Стоит отметить, что сам термин «анемичная модель» не осуждает отсутствие логики как таковое, он осуждает её потерю. Класс без поведения не плох сам по себе, он плох тогда, когда рядом существует бизнес-правило, которое логически принадлежит этому классу, но физически вынесено куда-то ещё только потому, что так было проще в момент написания первой версии.

Объектно-ориентированный подход предполагает, что объект инкапсулирует и данные, и поведение, которое с ними работает. Анемичная модель разрывает эту связку: данные становятся общедоступными через сеттеры, а правила их изменения расползаются по сервисам, которые знают о внутренней структуре объекта больше, чем сам объект.

Где живёт правилоАнемичная модельМодель с поведением
Проверка допустимости перехода статусаВ каждом сервисе, который меняет статусВ методе cancel() самого класса
Пересчёт суммы заказаРассеян по вызовам сеттеровВ методе recalculateTotal()
Риск несогласованного состоянияВысокий: сеттер можно вызвать в обход правилНизкий: инвариант проверяется внутри класса

Перенос поведения внутрь сущности

Лечение простое по формулировке и требует дисциплины на практике: методы, которые описывают действие над объектом, а не просто читают или пишут поле, переезжают внутрь класса. Сеттеры заменяются именованными методами, которые проверяют инвариант перед изменением состояния.

enum OrderStatus: string
{
    case New = 'new';
    case Paid = 'paid';
    case Cancelled = 'cancelled';
}

final class Order
{
    private OrderStatus $status = OrderStatus::New;
    private float $total = 0.0;

    public function cancel(): void
    {
        if (!in_array($this->status, [OrderStatus::New, OrderStatus::Paid], true)) {
            throw new \DomainException('Этот заказ уже нельзя отменить');
        }

        $this->status = OrderStatus::Cancelled;
    }

    public function status(): OrderStatus
    {
        return $this->status;
    }
}

После такой правки вызвать $order->cancel() безопасно из любого места приложения: проверка условия живёт один раз, внутри класса, а не копируется в админку, API и консольную команду отдельно.

Публичный сеттер, который ничего не проверяет, это приглашение поставить объект в недопустимое состояние откуда угодно. Если поле можно менять только по правилам, правило должно жить рядом с полем.

Есть и менее заметная цена: тесты для анемичной модели проверяют в лучшем случае, что геттер возвращает то же значение, которое записал сеттер. Это не тестирует бизнес-правило, потому что правила в самом классе нет. Настоящая проверка допустимости перехода статуса в такой архитектуре живёт в тестах сервиса, и если правило вызывается из нескольких сервисов, тестировать его приходится в каждом из них отдельно.

Вы в начале разбораДальше: разбор компромиссов, чек-лист и ответы на частые вопросы.

Когда анемичная модель не проблема

Не каждый класс с одними геттерами это ошибка. DTO (объект передачи данных), который переносит данные между слоями приложения и не содержит бизнес-правил по определению, анемичен по дизайну, и это нормально: его задача хранить данные, а не принимать решения. Проблема начинается, когда за DTO маскируется настоящая сущность домена с реальными правилами, которые просто забыли туда перенести.

  • DTO для передачи данных между слоями: анемичность это норма, не антипаттерн
  • Сущность с реальными бизнес-правилами: анемичность означает, что правила потеряны и живут где-то ещё
  • Простой справочник без правил изменения: отдельный конструктор и геттеры достаточны

На практике полезно явно называть такие объекты по-разному в коде и документации проекта: DTO, read-модель, сущность. Когда в команде есть общий словарь для этих трёх ролей, разговор о том, нужно ли добавлять метод в конкретный класс, становится короче, потому что роль класса сразу понятна из его названия и места в архитектуре, а не требует отдельного обсуждения каждый раз.

Ещё один практический довод в пользу переноса поведения в сущность: тестирование. Метод cancel() внутри класса Order можно проверить юнит-тестом без базы данных, очереди сообщений и HTTP-клиента, которые обычно тянет за собой сервисный слой. Чем ближе правило к данным, над которыми оно работает, тем дешевле его протестировать в изоляции.

Как не перегнуть в другую сторону

Обратная ошибка, «толстая модель», когда в сущность переносят вообще всё, включая работу с базой данных и HTTP-запросы, не лучше анемичной. Задача не «дать классу побольше методов», а оставить в нём те методы, которые отвечают за собственные инварианты объекта, и вынести во внешние сервисы оркестрацию между несколькими сущностями, работу с инфраструктурой и запросы к внешним системам.

  1. Найти в сервисах код, который напрямую меняет несколько полей одного объекта подряд
  2. Проверить, есть ли при этом условие допустимости: если да, это кандидат на метод внутри сущности
  3. Перенести метод и условие внутрь класса, оставить в сервисе только вызов этого метода
  4. Повторить для следующего поля, не пытаясь переписать всю модель за один проход

Анемичная доменная модель редко ломает приложение сразу. Она копит технический долг тихо: каждое новое место, где вызывается сеттер напрямую, увеличивает число точек, где правило можно забыть или нарушить. Перенос поведения внутрь сущностей не требует полного переписывания, его можно делать постепенно, по одному инварианту за раз. Если таких классов в проекте сотни и неясно, с какого правила начинать, помогает взгляд со стороны: независимый аудит кода обычно и начинается с карты мест, где бизнес-правила размазаны по сервисам, контроллерам и консольным командам.

Хороший ориентир для команды, которая начинает такой перенос: завести правило код-ревью, что любой новый публичный сеттер требует отдельного объяснения, почему связанное с ним бизнес-правило не может жить внутри класса. Во многих случаях формулировка такого объяснения сама подсказывает, что правило на самом деле есть, просто его забыли перенести вместе с полем.

Такой перенос не нужно делать резко по всему проекту. Достаточно договориться, что каждая новая сущность или каждый новый метод над существующей сущностью проектируется с поведением сразу, а старые анемичные классы переносятся в фоновом режиме по мере того, как к ним всё равно приходят вносить изменения по другим задачам.

Частые вопросы

Чем анемичная модель отличается от обычного DTO?

DTO анемичен по назначению: он переносит данные между слоями и не должен содержать бизнес-правил. Проблема возникает, когда под видом DTO скрывается сущность с реальными правилами, которые забыли реализовать.

Всегда ли анемичная модель это плохо?

Нет. Для справочников и объектов передачи данных без собственных инвариантов это нормальный и ожидаемый дизайн. Антипаттерном она становится, когда скрывает бизнес-логику, которая должна была быть в классе.

Как начать исправлять анемичную модель без переписывания всего проекта?

Выбрать один инвариант, например условие допустимости смены статуса, перенести его из сервиса в метод сущности, и повторять по одному правилу за раз, не пытаясь сделать это одним большим рефакторингом.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор