
Недавно состоялся у меня небольшой спор с коллегой, активно защищающим анемичные модели с размещением логики в сервисах. Несколько лет назад я и сам был последовательным фанатом такой разработки, пока не стал спотыкаться об ограничения этого подхода. В этом тексте хочу поделиться с вами плюсами и минусами анемичных моделей, как их вижу я.
Но сначала на всякий случай уточню, о чем идет речь. Анемичная модель - это такая сущность, в которой нет ничего, кроме полей, геттеров и сеттеров. То есть, это сущность низведенная до уровня тупой 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 и нарушение инкапсуляции.
Итого. Анемичные модели с логикой в сервисах - это вполне рабочий подход, который точно лучше, чем ничего, и который может отлично себя показать в двух случаях: на небольших проектах, или когда общий уровень экспертизы в команде не позволяет внедрять более тонкие подходы, требующие понимания ООП, солида и хороших архитектурных практик.
Как некую проходную стадию в профессиональном росте разработчика, я бы, наверно, счел этот подход чуть ли не обязательным. Но надо понимать, что на пути от хаотичного низкокачественного кода к чистому, хорошо структурированному, легко читаемому и сопровождаемому коду, подход с анемичными моделями скорее первый шаг, и уж точно далеко не последний.