Два типа классов для вашего проекта
Вопрос о зоне ответственности того или иного класса в вопросах архитектуры не менее важен, чем ее стратегическое планирование. Сегодня я расскажу о том, как облегчить себе эту работу. Мы с вами решим довольно большое количество вопросов, из бесконечного многообразия классов оставив всего 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
-методы, возвращающие новый объект того же класса с измененными данными. Лично мне приятнее называть переменную $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, ни сервисом, нужны были вам? Рассказать и подискутировать всегда можно в моем телеграм-чате.