
Недавно состоялся у меня небольшой спор с коллегой, активно защищающим анемичные модели с размещением логики в сервисах. Несколько лет назад я и сам был последовательным фанатом такой разработки, пока не стал спотыкаться об ограничения этого подхода. В этом тексте хочу поделиться с вами плюсами и минусами анемичных моделей, как их вижу я.
Но сначала на всякий случай уточню, о чем идет речь. Анемичная модель - это такая сущность, в которой нет ничего, кроме полей, геттеров и сеттеров. То есть, это сущность низведенная до уровня тупой DTO. Вся логика при этом выносится из entity в другие классы. Чаще всего под логику выделяют т. н. "сервисный слой" - по сути, просто папку Service
в которой лежат классы, реализующие различные кейсы и бизнес-правила.
В DDD анемичная модель считается антипаттерном. Весь DDD по сути построен на том, чтобы модели сами держали свои бизнес-правила (инварианты), ну да речь сейчас не про DDD (если интересно, тему DDD, агрегатов и инвариантов я рассматриваю, например здесь и здесь).
Итак, вот плюсы подхода к разработке Анемичная модель + сервисный слой.
Во-первых, этот подход очень легок для понимания и реализации на начальных стадиях проекта. По сути, у вас всего два компонента, которыми вы оперируете: модель данных (какие поля в каких сущностях сделать) и бизнес-правила, которые вы помещаете в отдельные классы. Это действительно просто.
Во-вторых, при всех своих недостатках, это все-таки методичный подход. А значит, проект, на котором он внедрен, значительно превосходит любой проект, где нет никакого внятного и последовательного подхода к разработке (а таких, проектов, увы, немало).
В-третьих, этот подход превосходно сочетается с дефолтной симфонийской структурой проекта. Symfony из коробки дает нам папки Entity, Controller и Repository. Добавить в эту структуру папку Sevice кажется вполне закономерным. В сочетании с хорошей структурой конфигов фреймворка, сверхмощным DIC, и продуманным использованием компонентов Symfony, можно поддерживать кодовую базу в чистоте и порядке на протяжении всего срока существования проекта.
Из плюсов, пожалуй, все. Самый главный плюс - это простота понимания. Даже джуна можно быстро обучить. И контролировать соблюдение подхода командой тоже легко: банального поверхностного кросс-ревью будет достаточно.
Ну а теперь о минусах.
Проще всего их продемонстрировать на простом примере кода. Давайте представим такой бизнес-кейс: чтобы активировать бонусную программу для клиента, необходимо убедиться, что в профиле клиента заполнен определенный набор полей. Сам факт активации (как и деактивации) бонусной программы должен сохраняться в истории начисления бонусов клиенту. Это нужно для исключения вопросов, почему не начислились бонусы за ту, или иную покупку: если бонусный счет неактивен в какой-то период, то и бонусов за покупки в этом периоде нет.
Давайте накидаем быстренько код в подходе "Анемичная модель + сервисы".
namespace App\Entity\Bonus
class BonusAccount
{
private string $id;
private string $clientId;
private BonusAccountStatus $status;
private int $balance;
//Другие поля, сеттеры и геттеры
}
class BonusHistoryRecord
{
private string $bonusAccountId;
private \DateTimeImmutable $dateTime;
private BonusHistoryEvent $event;
private ?int $amount = null;
//Другие поля, сеттеры и геттеры
}
namespace App\Service\Bonus
class BonusAccountService
{
public function __construct(
private readonly BonusAccountRepositoryInterface $bonusRepository,
private readonly ClientRepositoryInterface $clientRepository,
private readonly BonusHistoryRecordRepositoryInterface $historyRepository,
private readonly TransactionalSessionInterface $transactionalSession
) {}
public function activate(string $id): void
{
$account = $this->bonusRepository->byId($id);
$client = $this->clientRepository->byId($account->getClientId());
$profile = $client->getProfile();
//Первое бизнес-правило: поля профиля должны быть заполнены
if (empty($profile->getName()) || empty($profile->getPhone())) {
//Представьте, как будет выглядеть этот if для 10 обязательных полей
throw new new \DomainException();
}
//Второе бизнес-правило: активация программы обязательно должна быть внесена в историю
$record = (new BonusHistoryRecord())
->setDateTime(new \DateTimeImmutable())
->setBonusAccountId($account->getId)
->setEvent(BonusHistoryEvent::ACCOUNT_ACTIVATED);
//Ну и наконец активируем бонусный счет и сохраняем
$account->setStatus(BonusAccountStatus::ACTIVE);
$this->transactionalSession->transactional(function () use ($account, $record) {
$this->bonusRepository->save($account);
$this->historyRepository->save($record);
});
}
}
Обратите внимание: несмотря на то, что я не использую в этом коде DDD, я продолжаю придерживаться принципа инвертирования зависимостей и слоистой архитектуры. У меня написан сервис с бизнес-логикой, но он никак не завязан на конкретные инфраструктурные компоненты, такие как Doctrine, ActiveRecord, Eloquent и т. п.
Я неспроста на этом акцентируюсь: часто в такие сервисы с логикой тащат инфраструктурные зависимости, такие как доктриновский EntityManager
. При таком подходе код становится существенно грязнее, а минусы подхода "Анемичная модель + сервисы" раскрывают себя гораздо глубже и быстрее.
Прежде чем рассматривать минусы подхода, давайте еще взглянем на код ниже, который решает ту же самую задачу.
Пример кода с применением базовых принципов DDD.
class BonusHistoryRecord
{
private string $bonusAccountId;
private \DateTimeImmutable $dateTime;
private BonusHistoryEvent $event;
private ?int $amount = null;
public function __construct(string $bonusAccountId, BonusHistoryEvent $event)
{
$this->dateTime = new \DateTimeImmutable();
$this->bonusAccountId = $bonusAccountId;
$this->event = $event;
}
}
class BonusHistory
{
private string $accountId;
/** @var BonusHistoryRecord[] */
private array $records;
public function __construct(string $accountId, BonusHistoryRecord ...$records)
{
$this->records = $records;
$this->accountId = $accountId;
}
public function accountActivated(): void
{
$this->records[] = new BonusHistoryRecord($this->accountId, BonusHistoryEvent::ACCOUNT_ACTIVATED);
}
}
class BonusAccount
{
private string $id;
private string $clientId;
private BonusAccountStatus $status;
private int $balance;
private BonusHistory $history;
public function activate(Client $client): void
{
//Профиль - часть агрегата клиента, и он сам про себя знает, какие поля в нем обязательны
if (!$client->profileComplete()) {
throw new \DomainException();
}
$this->history->accountActivated();
}
}
namespace Application\Command;
//под Application здесь понимается именно слой из слоистой архитектуры
class ActivateBonusAccount
{
public function __construct(
private readonly BonusAccountRepositoryInterface $bonusRepository,
private readonly ClientRepositoryInterface $clientRepository,
) {
}
public function execute(): void
{
$account = $this->bonusRepository->byId($id);
$client = $this->clientRepository->byId($account->getClientId());
$account->activate($client);
$this->bonusRepository->save($account);
}
}
Код какого из вариантов чище и лаконичнее, судите сами (с учетом того, что мы обычно в моменте смотрим в какой-то один класс). Но моя сегодняшняя цель не столько сравнить два подхода, сколько поговорить именно о плюсах и минусах анемичных моделей с логикой в сервисах.
Чтобы понять минусы, представьте, что BonusAccountService
написал один разработчик, который больше не работает в команде, а год спустя нужно реализовать активацию и деактивацию бонусного счета в зависимости от сложной логики смены статуса клиента.
Первый минус, который из этого вытекает, состоит в том, что вам придется аккумулировать и контролировать знания о том, а какие в проекте есть сервисы с логикой. Если разработчик, взявший новую задачу в работу, не узнал про BonusAccountService
или не увидел его в структуре проекта, то легко возникнет ситуация, когда в рамках новой логики бонусный счет может быть активирован без соблюдения уже существующих правил: обязательного заполнения профиля и записи события активации в историю.
Даже если вам удастся миновать первую проблему (а это, поверьте мне, во многом зависит от случая), то вы столкнетесь со второй проблемой. Чтобы включить старую бизнес-логику в новую, вам придется сделать одно из двух: либо продублировать код со всеми вытекающими, либо запутать код, притащив BonusAccountService
в зависимости нового сервиса, и вызывая его там.
Таким образом, BonusAccountService
, который мы рассмотрели выше, хорошо подойдет для вызова из контроллера. Также, как для этого хорошо подходят Applicaton команды(кому интересно, вот мой пост про них). Но вот при использовании его в составе какой-то другой логики начинаются проблемы, описанные выше.
Заключение и резюме
На этом, пожалуй, все. Давайте коротко повторю плюсы подхода "Анемичная модель + сервисы".
Легко понять и реализовать на начальной стадии разработки.
Легко контролировать его соблюдение, даже малоопытные участники команды смогут подсвечивать друг друг отклонения.
Лучше, чем ничего: все-таки это структурированный подход, и он задает и держит форму кодовой базы.
Отлично сочетается с "коробочным" подходом использования Symfony.
Минусы подхода:
Сложно каталогизировать сервисы-куски бизнес-логики, легко пропустить существующий сервис. Нужен кто-то с хорошей экспертизой проекта, кто будет за этим следить.
Код с логикой либо дублируется от сервиса к сервису, либо запутывается из-за инжекта одних сервисов в другие.
Как следствие, по сравнению с применением хотя бы основ DDD, получаем более высокий coupling, более низкий cohesion и нарушение инкапсуляции.
Итого. Анемичные модели с логикой в сервисах - это вполне рабочий подход, который точно лучше, чем ничего, и который может отлично себя показать в двух случаях: на небольших проектах, или когда общий уровень экспертизы в команде не позволяет внедрять более тонкие подходы, требующие понимания ООП, солида и хороших архитектурных практик.
Как некую проходную стадию в профессиональном росте разработчика, я бы, наверно, счел этот подход чуть ли не обязательным. Но надо понимать, что на пути от хаотичного низкокачественного кода к чистому, хорошо структурированному, легко читаемому и сопровождаемому коду, подход с анемичными моделями скорее первый шаг, и уж точно далеко не последний.
Комментарии (12)
gsaw
17.06.2025 11:47Ну во первых entity классы не всегда пишутся руками, они еще могут и генерироваться из описания к примеру. Там уже логику замучаешься втискивать. Потому имхо, если уж решили логику отдельно от данных держать, то везде, во всех проектах.
Во вторых, если понадобятся dto, то придется писать еще отдельные классы dto, а так можно взять эти пустоголовые классы и использовать в межмодульном общении.
Во третьих, логика еще подразумевает обращение к каким то сторонним сервисам. Вы для этого создали по сути тот же сервис ActivateBonusAccount со странным именем больше подходящим какому то методу. Уж тогда BonusAccountActivationService ;). И получилось, что у вас логика размазалась. Может потом в условии активации надо будет опросить еще и какой то сторонний сервис?
В четвертых, Если у меня есть UserEntity и есть UserService с методом activateUser, почему мне должно прийти в голову писать еще один сервис с таким же методом? Как можно этот сервис не заметить? Нашел UserEntity и не нашел UserService? Либо бардак с именованием в проекте, или программируете в блокноте.
vanxant
17.06.2025 11:47Таки и что вы предлагаете? Тащить бизнес-логику в модели, как в yii, и получать классы на 10к строк и с зависимостями на половину проекта?
olku
17.06.2025 11:47Не со всем согласен, но статья отличная, спасибо. DTO это все же предельный, вырожденный случай анемичной модели, откуда выброшено все, в том числе и валидация состояния.
vanxant
17.06.2025 11:47ну конкретно в симфони валидацию из ДТО никто не выкидывал
olku
17.06.2025 11:47Детали важны. Валидацию можно делать а можно не делать, в статье этот момент пропущен обобщением, что анемичная модель это антипаттерн. Как хочешь так и понимай. VO тоже можно назвать анемичной моделью, там же нет бизнес логики.
PerroSalchicha
17.06.2025 11:47Тут на самом деле вопрос нехитрый: если ваши модели имеют транспортную функцию, будь-то между системами, для сериализации куда-то, для сохранения в БД, они должны быть анемичнее некуда, максимум - иметь методы для сериализации и преобразования. Если это модели для бизнес-логики, то там бизнес-логика вполне себе может быть уместной, например, связанная с валидацией полей самой модели.
funca
17.06.2025 11:47нужно реализовать активацию и деактивацию бонусного счета в зависимости от сложной логики смены статуса клиента.
...
Чтобы включить старую бизнес-логику в новую, вам придется сделать одно из двух: либо продублировать код со всеми вытекающими, либо запутать код, притащив BonusAccountService в зависимости нового сервиса, и вызывая его там.
Это вообще нормально, что при каждом изменении правил со стороны бизнеса нужно менять код приложения и раскатывать клиентам новую версию? Вряд-ли авторы DDD имели ввиду именно это, когда предлагали помещать бизнес-логику в модели домена.
aikus
Ну если совсем честным быть, то в при любом подходе, на сложном проекте нужен кто-то с хорошей экспертизой проекта. Это, как мне кажется, не технический вопрос.
slayervc Автор
Да, сам факт необходимости в таких людях, конечно не оспаривается.) Я это писал к тому, что когда проектируешь какой-то кусок, постоянно приходится думать: вот придут ребята, начнут его дописывать по-своему, использовать по-своему, а кто-то не увидит и сделает похожее.
Эти вопросы всегда висят в воздухе и постоянно приходится думать, как реализовать удобное для переиспользования и сопровождения решение. Желательно так, чтобы вообще не пришлось объяснять, что это и зачем. И уж тем более, чтобы не следить потом зорким глазом на ревью, ударяя по рукам всякому, кто отклонился от твоего замысла.)