Pull to refresh

Анемичные модели с логикой в сервисах: плюсы и минусы одного из самых популярных подходов к разработке на PHP

Level of difficultyEasy
Reading time7 min
Views1.6K

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

Но сначала на всякий случай уточню, о чем идет речь. Анемичная модель - это такая сущность, в которой нет ничего, кроме полей, геттеров и сеттеров. То есть, это сущность низведенная до уровня тупой 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 и нарушение инкапсуляции.

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

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

Tags:
Hubs:
+6
Comments12

Articles