Два типа классов для вашего проекта

June 16, 2022
Около 4 мин

Вопрос о зоне ответственности того или иного класса в вопросах архитектуры не менее важен, чем ее стратегическое планирование. Сегодня я расскажу о том, как облегчить себе эту работу. Мы с вами решим довольно большое количество вопросов, из бесконечного многообразия классов оставив всего 2 их типа: ДТО и Сервис. Эти типы классов покрывают 90-99% всего необходимого в любом проекте. Итак, разберём, что они из себя представляют, и чем же так хорошо оставить только их.

Два типа классов для вашего проекта

Инфо

Здесь и дальше в качестве примера будем использовать интернет-магазин.

Существует два основных подхода к организации работы с сущностями: так называемые "богатая" и "анемичная" модели. Богатая модель подразумевает, что сущность сама знает, что и как с ней можно сделать. Итак, в интернет-магазине есть такая сущность, как "заказ". В богатой модели предметной области его интерфейс будет выглядеть примерно так:

interface OrderInterface
{
    public function getId(): OrderId;
    public function getProducts(): array;
    public function getCustomer(): Customer;
    public function getStatus(): OrderStatus;
    public function create(): void;
    public function update(): void;
    public function delete(): void;
    public function moveTo(OrderStatus $status): void;
    public function removeProduct(Product $product): void
    // etc.
}

То есть он содержит методы, не только сообщающие информацию о состоянии заказа (геттеры), но и каким-то образом его изменяющие: сохраняющие в БД, изменяющие статус и пр.

Логика подсказывает, что у такого подхода будут проблемы с принципами Single Responsibility и Interface Segregation из SOLID, а связанность (coupling) кода будет крайне высокой (вы только представьте, сколько действий в других подсистемах надо совершить, чтобы создать заказ: платежи, логистика, склад и пр.). Кроме того, я ни разу не видел успешного применения этого подхода. Не исключаю вариант, что просто не умею готовить богатую модель. Так что если у вас будет пример проекта, в котором она оправданно применена, буду рад увидеть его код.

Итак, мы подробнее остановимся на "анемичной модели". Называется она так потому, что у сущности нет никаких дополнительных обязанностей и знаний о чем бы то ни было, кроме ее собственного состояния. То есть, если брать пример выше, у Заказа останутся только геттеры. А начиная с php8.1, можно даже без них:

class Order
{
    public function __construct(
        public readonly OrderId $id;
        /** @var Product[] */
        public readonly array $products;
        public readonly Customer $customer;
        public readonly OrderStatus $status;
    ) {
    }
}

Итак, сущность в анемичной модели предметной области является DTO (Data Transfer Object, объектом для передачи данных).

Заметка

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

Data Transfer Object

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

Ключевыми особенностями DTO являются два нюанса:

Только представление данных, и ничего больше

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

Иммутабельность

DTO по сути неизменяемы, и это их главное преимущество. Зачем? А представим, что DTO может измениться.
Допустим, у нас есть такой код: $service->foo($dto);, и мы знаем, что методу foo вообще незачем изменять состояние переданной в него DTO. Но время идет, код меняется, и где-то ниже по цепочке вызовов, не в самом foo появилось нечто подобное: $dto->bar = 'baz';. Как на это отреагирует код, который идет после вызова foo? Когда вы заметите, что поведение изменилось? Сколько часов уйдет на отладку?
Либо мы можем сразу сделать объект иммутабельным любым из этих способов:

  • пометить все поля readonly
  • сделать их private и обеспечить доступ через геттеры
  • пометить класс @psalm-immutable, если Psalm используется в CI

Как же тогда внести изменения в данные? Никак. В этом и суть. Если бизнес-логика подразумевает изменение данных - необходимо создать новый объект с этими новыми данными. Иногда DTO может само подразумевать подобные операции. Для этого в соглашениях команды YiiSoft есть with-методыopen in new window, возвращающие новый объект того же класса с измененными данными. Лично мне приятнее называть переменную $instance, а не $new, но это уже мелочи.

Сервис

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

Хранение состояния

Это единственная по-настоящему грубая ошибка при проектировании любого сервиса. Если сервис хранит некое состояние, есть большие шансы получить неожиданные сайд-эффекты при его повторном использовании. Если сервис не хранит никакого состояния, его легко можно использовать снова в другом месте. Либо в том же, в рамках того же php-процесса. А это не только облегчает дальнейшую разработку, но и даёт возможность использовать инструменты вроде RoadRunner и Swoole.
Итак, неправильно: $service->setData($data)->foo(), правильно: $service->foo($data). Если вам все же кажется необходимым в вашем сервисе оставить состояние для работы каких-то отдельных методов, вполне вероятно, что вам стоит разделить этот сервис на два.

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

  • Зависимости - это другие сервисы, позволяющие текущему делать его работу. Просто следуем принципу dependency Inversion и доставляем классу его зависимости в конструктор.
  • Настройки - значения, позволяющие сервису работать. Данные для подключения к БД у репозитория, веб-адрес для API-клиента, префикс у кеша и т.п.
  • Рантайм-кеш хранит в себе уже вычисленные значения в случае, если эти значения не изменяются со временем, а их повторное вычисление ресурсозатратно. Выглядит его использование примерно так:
    if (!isset($this->cache[$id]) {
      $this->cache[$id] = $this->build($id);
    }
    
    return $this->cache[$id];
    
  • Состояние же не задаётся глобально для всего проекта/модуля, а изменяется в зависимости от контекста. Это единственный вид данных в сервисе, который может меняться в течение выполнения программы. Именно над ним или с его помощью сервис выполняет какую-то работу.

Таким образом, "хорошие" сервисы могут иметь зависимости, настройки и рантайм-кеш, а DTO - только состояние.

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

А какие шаблоны проектирования, не являющиеся ни DTO, ни сервисом, нужны были вам? Рассказать и подискутировать всегда можно в моем телеграм-чатеopen in new window.