Еще немного про сервисный слой в PHP

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

Сегодня мы поговорим об одном из способов организации бизнес логики - сервисном слое (он же service layer), когда и зачем его нужно применять, а также какие проблемы архитектуры он поможет решить. Примеры реализации будут показаны с использованием архитектурного паттерна MVC и фреймворка Laravel.

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

Стоит также заметить, что Service layer - не панацея всего. Это всего лишь один из подходов к структурированию кода в вашем приложении, который будет поддаваться расширению и будет понятен для других программистов.

Основы

Если обратиться к теории, то сервисный слой можно описать таким определением:

Сервисный слой (Service layer) — это шаблон проектирования, который инкапсулирует бизнес логику вашего приложения и определяет границу и набор допустимых операций с точки зрения взаимодействующих с ним клиентов.

Думаю, что звучит запутанно и сложно. Если простыми словами, то вы сосредотачиваете логику вашего приложения в одном (или нескольких) классе-сервисе, а в своих контроллерах обращаетесь к нему. Это избавляет от дублирования кода в разных участках системы, делая ваш контроллер действительно соответствующим букве S из SOLID.

Мы не будем рассматривать примеры, где показывают работу с Eloquent в контроллере, как его выносят в сервисный слой и т.д. Такие примеры, на мой взгляд, достаточно абстрактно описывают проблему и не показывают преимущества сервисного слоя. Вместо этого, мы рассмотрим реализацию классической логики интернет-магазина, где при определенных событиях, система оповещает клиента определенными сообщениями. Итак, начнем.

Email уведомления

Представим, что вы разрабатываете интернет-магазин и задача на текущий день - написать код, который будет оповещать клиента об успешном оформлении заказа. Реализуем эту простую логику.

namespace App\Http\Controllers;

use App\Http\Requests\CreateOrderRequest;
use Illuminate\Support\Facades\Mail;

class OrderController
{
    public function createOrder(CreateOrderRequest $request)
    {
        // Логика создания заказа...

        Mail::send('mail.order_created', [
            'order' => $order
        ], function ($message) use ($order) {
            $message->to($order->email)
                ->subject(trans('mail/order_created.mail_title'));
        });
    }
}

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

public function editOrder(EditOrderRequest $request)
{
    // Логика обновления данных заказа...

    Mail::send('mail.order_updated', [
        'order' => $order
    ], function ($message) use ($order) {
        $message->to($order->email)
            ->subject(trans('mail/order_updated.mail_title'));
    });
}

И конечно, нам нужно поприветствовать клиента, когда он решил стать нашим постоянным покупателем.

public function registerCustomer(RegisterCustomerRequest $request)
{
    // Логика регистрации пользователя...

    Mail::send('mail.customer_register', [
        'customer' => $customer
    ], function ($message) use ($customer) {
        $message->to($customer->email)
            ->subject(trans('mail/customer_register.mail_title'));
    });
}

Таким образом, мы решаем все последующие задачи оповещений с помощью фасада Mail и все идет по плану, до тех пор, пока наш интернет-магазин не начинает стремительно набирать обороты.

Набор оборотов

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

Выше, мы реализовали три отправки email сообщений в трех разных частях системы, а поскольку мы рассматриваем близкую к реальности ситуацию, то по мере развития интернет-магазина будет реализовано еще очень много таких отправок. А теперь представьте, что нам нужно пройтись по всем частям системы и заменить старый код с фасадом Mail на новую логику отправки с помощью сервиса рассылок. Сколько времени необходимо на это потратить и сколько тестов нужно переписать (если код конечно покрывался тестами)? И чем больше кода разработчику необходимо изменить, тем больше вероятность допущения ошибки по причине человеческого фактора. Хорошо еще, если разработчик вынесет логику обращения к сервису рассылок в отдельный класс, а не будет дублировать код по всем частям системы. Чтобы не попадать в такие ситуации, перепроектируем систему с применением сервисного слоя.

Сервисный слой

Для начала, давайте инкапсулируем логику уведомлений в новый класс NotificationService.

namespace App\Services;

use Illuminate\Support\Facades\Mail;
use App\Mail\Events\MailEventInterface;
use App\Mail\Events\OrderCreatedEvent;
use App\Mail\Events\OrderUpdatedEvent;
use App\Mail\Events\CustomerRegisterEvent;

class NotificationService
{
    public function notify(string $event, array $data)
    {
        $event = $this->makeNotificationEvent($event, $data);

        Mail::send($event->getView(), $event->getData(), function ($message) use ($event) {
            $message->to($event->getEmail())
                ->subject($event->getMailSubject());
        });
    }

    private function makeNotificationEvent(string $event, array $data) : MailEventInterface
    {
        switch ($event) {
            case 'order_created':
                return new OrderCreatedEvent($data);
            case 'order_updated':
                return new OrderUpdatedEvent($data);
            case 'customer_register':
                return new CustomerRegisterEvent($data);
            default:
                throw new \InvalidArgumentException("Undefined event $event");
        }
    }
}

Далее, создадим интерфейс MailEventInterface.

namespace App\Mail\Events;

interface MailEventInterface
{
    public function getView() : string;
    public function getData() : array;
    public function getEmail() : string;
    public function getMailSubject() : string;
}

А также, в качестве примера, напишем новый класс OrderCreatedEvent (оповещение клиента об успешном оформлении заказа).

namespace App\Mail\Events;

class OrderCreatedEvent implements MailEventInterface
{
    private $order;

    public function __construct(array $data)
    {
        // Логика валидации (на любителя)

        $this->order = $data['order'];
    }

    public function getView(): string
    {
        return 'mail.order_created';
    }

    public function getData(): array
    {
        return [
            'order' => $this->order
        ];
    }

    public function getEmail(): string
    {
        return $this->order->email;
    }

    public function getMailSubject(): string
    {
        return trans('mail/order_created.mail_title');
    }
}

Теперь мы можем переписать наш контроллер, используя сервисный слой.

namespace App\Http\Controllers;

use App\Http\Requests\CreateOrderRequest;
use App\Services\NotificationService;

class OrderController
{
    private $notificationService;
    
    public function __construct(NotificationService $notificationService)
    {
        $this->notificationService = $notificationService;
    }

    public function createOrder(CreateOrderRequest $request)
    {
        // Логика создания заказа...
        
        $this->notificationService->notify('order_created', [
            'order' => $order
        ]);
    }
}

Что у нас получилось? Мы передали ответственность за отправку писем сервисному слою, избавив наш контроллер от дополнительных обязанностей. Теперь контроллеру не важно, каким образом клиент будет оповещен о нужных событиях, он лишь знает как использовать предоставленный ему интерфейс. Если в будущем нам поступит задача об отправке оповещений через сервис рассылок (или еще что-нибудь), то мы изменим реализацию лишь в одном месте, вместо замены десятка раскиданных по системе реализаций. На этом этапе, я надеюсь, что преимущества сервисного слоя перед традиционным подходом "раздутый контроллер" стали очевиднее. Теперь поговорим о деталях.

Нужно ли объявлять интерфейс для сервисного слоя?

И да и нет. Ответ тут зависит от ситуации. Взгляните на пример выше. Этот код прекрасно проявит себя в деле, если поступит задача отправлять все письма через сервис рассылок. Но что если нам понадобиться перевести лишь часть событий? В таком случае, гораздо эффективнее было бы объявить общий интерфейс NotificationServiceInterface и в зависимости от контроллера, пробрасывать соответствующую реализацию в нашем сервис-провайдере. Что-то по типу этого.

$this->app->when(OrderController::class)
    ->needs(NotificationServiceInterface::class)
    ->give(function () {
        return new ESputnikNotificationService();
    });

$this->app->when(OrderUpdateController::class)
    ->needs(NotificationServiceInterface::class)
    ->give(function () {
        return new MailNotificationService();
    });

К слову, в 95% случаях, интерфейсы для сервисного слоя все-таки не нужны.

Можно ли использовать сервисы внутри сервисов?

Я бы однозначно не рекомендовал такую практику, так как этим вы нарушаете single responsibility принцип, делая ваш код, к тому же, достаточно запутанным.

Работу с несколькими сервисами можно организовать такими способами.

1. Внедрением зависимостей в контроллер и поочередный их вызов в экшене. В таком случае отлов неудач можно, например, делегировать блоку try/catch.

class OrderController
{
    public function saveOrder(
        SaveOrderRequest $request, 
        OrderService $orderService, 
        NotificationService $notificationService
    ) {
        try {
            $order = $orderService->createOrderFromRequest($request);
            $notificationService->notify('order_created', [
                'order' => $order
            ]);

            return response()->json([
                'success' => true,
                'data' => [
                    'order' => $order
                ]
            ]);
        }
        catch (OrderServiceException|NotificationServiceException $e) {
            return response()->json([
                'success' => false,
                'exception' => $e->getMessage()
            ]);
        }
    }
}

2. Выделение класса, которому можно делегировать работу с цепочкой сервисов. Например, я обычно использую класс с суффиксом Operation (CreateOrderOperation). Ошибки можно все также отлавливать с помощью try/catch, но гораздо практичнее будет ввести сущность OperationResult, которую будет возвращать каждая операция в не зависимости от результата выполнения. Это способ мне нравится больше.

class OrderController
{
    public function saveOrder(
        SaveOrderRequest $request,
        CreateOrderOperation $createOrderOperation
    ) {
        // Внутри операции выполняются все обращения к сервисам и т.д.
        $result = $createOrderOperation->createOrderFromRequest($request);

        // Для более чистого экшена, сущность OperationResult
        // может имплементировать JsonSerializable

        return response()->json($result);
    }
}

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

UPD: И конечно, не совсем правильно передавать в слой сервиса лишние данные в виде целого request. Намного лучше будет пробросить валидный DTO. Возвращать из сервисов нужно тоже что-то понятное. Этот подход имеет смысл как минимум в экосистеме Laravel.

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

Всем спасибо за внимание!

Комментарии 19

    +1
    1. Зачем вызывать явно «response()->json»? Фреймворк сам разберется, в каком формате сериализовать данные.

    2. А вот пример в Вашем коде, как не нужно делать:
    return response()->json([
                    'success' => false,
                    'exception' => $e->getMessage()
                ]);
    

    Лучше отвечать кодом HTTP. Так же, фреймворк предоставляет для этого все нужное, используя настройки окружения (debug флаг) для более развернутой информации про ошибку

    3. try-catch конструкция внутри контроллера — лишнее. Есть ExceptionHandler из коробки

    4. Вот в этом месте
    $orderService->createOrderFromRequest($request)
    

    Вы рискуете получить лишние данные прямо в сервисный слой. Используйте метод "$requests->validated()"
      +1
      Спасибо за комментарий. Как я и описывал выше, цель статьи — показать преимущества создания слоя сервисов при разработке архитектуры веб приложений. Возврат с помощью response() имеет тут чисто показательный характер.

      Вы рискуете получить лишние данные прямо в сервисный слой. Используйте метод "$requests->validated()"

      Да, так действительно будет гораздо лучше. Ну или определить внутри request метод, который вернет сущность (для более удобной работы) вместо массива. Спасибо за замечание)
        0
        п.2 почему так не нужно делать?
          0
          Дизайн rest api предполагает, что код ответа (http статус) уже несет в себе нужную информацию. Вы можете дополнить тело ответа информацией про ошибку, при условии отладки. Автор статьи игнорирует рекомендации по дизайну и всегда отдает сообщение из исключения, в обход ExceptionHandler'а
            0
            Какие статусы вы назначите на GET /order

            Для следующих случаев:
            • Заказ не существует.
            • Заказ не найден в рамках существующих фильтров.

              0
              Полагаю, что в Вашем вопросе есть ошибка :)

              GET /order — не корректный запрос/uri
              GET /orders — получить коллекцию заказов. Если в рамках существующих фильтров ничего не найдено, вернуть пустую коллекцию с кодом 200 (запрос же корректно был обработан). Как альтернатива, можно вернуть код 400, но как по мне — это уже дело вкуса…
              GET /order/{id} — вернет 200, если заказ (ресурс) с таким id существует. 404, если заказ не найден, не корректный id или клиенту нельзя знать о существовании такого id

                0
                GET /order/{id} — вернет 200, если заказ (ресурс) с таким id существует. 404, если заказ не найден, не корректный id или клиенту нельзя знать о существовании такого id

                Полагаю что вы вообще не понимаете в чем суть проблемы отборажения бизнеслогики на статусы HTTP.
                Первой много, а второго мало.

                По шагам
                404, если заказ не найден

                Это ок.

                404, если зне корректный id

                Это не ок. Не пользователь должен догадаться из вашего "что-то пошло не так". А он должен получить внятную информацию. Особенно если это пользователь API.

                404, если клиенту нельзя знать о существовании такого id

                Это тоже не ок. Поскольку совершенно другая ситуация которая требует иной реакции.

                Тут люди свой собственный пол сделали небинарным. А вы хотите завернуть всю широту возможных случаев в несколько десятков кодов…

                Ваш код 404 не несет всей нужной информации. Т.е. нарушает сам REST по вашим же словам.

                Как исправлять будете?
                  0
                  Пожалуй, соглашусь с Вашим вариантом, отвечать другим кодом, отличным от 404 при ошибочном id или отсутствия доступа. Холиварить тут нет смысла :)
        0
        Можно ли использовать сервисы внутри сервисов?
        Я бы однозначно не рекомендовал такую практику, так как этим вы нарушаете single responsibility принцип, делая ваш код, к тому же, достаточно запутанным.

        Тут нет ошибки? Потому что во втором пункте вы по сути предлагаете использовать сервисы (OrderService и NotificationService) внутри сервиса (CreateOrderOperation)

          –1
          Нет, в этом случае CreateOrderOperation — это отдельная абстракция над слоем сервисов. Тут мы можем провести аналогию с фасадом, который скрывает сложную (или не очень) систему классов за простым интерфейсом. Я использую названия в стиле ..Operation, так как обычно за один запрос нам необходимо выполнить последовательность каких-то действий (сохранить заказ, сгенерировать платежный виджет, отправить оповещение клиенту и т.д.), что по сути своей можно назвать атомарной операцией.
            0
            Только по сути ваш CreateOrderOperation завязан на контроллер, является его продолжением вынесенным в другое место, толку от него такого особо нет. На самом деле он всё таки должен относится к сервисному слою, принимать на вход какую нибудь дтошку и возвращать тоже что то понятное.

            А всё что относится к http и валидации глубже контроллера не следует пропускать.
          +2
          Меня одного покорежило от switch в makeNotificationEvent?
            –1
            Если вы намекаете на match, то в силу текущих задач я все еще очень часто использую php ниже 8-й версии, так что пишу, как привык)
              0

              Почему сразу не передавать MailEventInterface?


              NotificationService::notify(MailEventInterface $event): void;

              Заодно можно будет избавится от массива, а сразу передавать нужные параметры в эвент. В противном случае каждый раз надо будет заходить в реализацию makeNotificationEvent, смотреть какой эвент должен создаться при order_created и какой массив параметров он ожидает


              public function createOrder(CreateOrderRequest $request)
              {
                  $this->notificationService->notify(new OrderCreatedEvent($order)); 
              }
                0
                Теперь понял) да, это действительно имеет смысл. Но тут также может возникнуть вопрос: «Должен ли контроллер знать и уметь создавать такую зависимость?».
                  0
                  Ну, конкретно в данном случае есть laravel.com/docs/7.x/events#generating-events-and-listeners, где можно в заказах генерить соответствующие эвенты, а сервисный слой должен подписаться на соответствующий эвент с нужным генератором рассылки. Опять же, через этот механизм можно непринужденно можно расцепить само создание эвента и рассылку сообщений во времени, используя очереди (не задерживать обработку основного запроса).
            +3
            $result = $createOrderOperation->createOrderFromRequest($request);

            Это не сервисный слой, а насмешка над ним. Идея разделения на слои в том, что каждый слой отвечает за что-то свое: контроллер отвечает за взаимодействие с пользователем (прием HTTP-запросов, формирование ответов), а сервис отвечает за бизнес-логику, связанную с созданием заказа. У вас же сервис выполняет работу контроллера: анализирует HTTP-запрос и формирует HTTP-ответ.


            Это не сервис, это тот же самый толстый контроллер, который вы просто назвали Operation. В то же время контроллер у вас вообще ничего не делает. Зачем он вообще нужен? Удалите контроллер и переименуйте Operation в Controller и все будет правильно.


            Из-за отсутствия разделения на слои с кодом может быть неудобно работать. Если например, я захочу создать заказ из CLI скрипта: надо откуда-то взять Request (откуда?), заполнить его (чем? Это нигде не определено, откуда я узнаю, какие в нем параметры?). Если бы у вас был по настоящему отделенный от контроллера сервисный слой, вы легко могли бы создать заказ из CLI или из теста.

              –1
              Можно ли использовать сервисы внутри сервисов?

              Я бы однозначно не рекомендовал такую практику, так как этим вы нарушаете single responsibility принцип, делая ваш код, к тому же, достаточно запутанным.

              Разве? Вот есть у нас ряд действий: создать заказ, уведомить на почту, уведомить смской,… за каждое действие отвечает свой сервис.

              Теперь нам надо чтоб в каком то случае всё это произошло в определенной последовательности и чтоб мы эту операцию могли вызывать из разных мест приложения. Эта операция и становится сервисом, его область ответственности — содержать логику последовательности действий, он будет изменяться, когда мы захотим изменить набор действий, их порядок. Код станет проще, поскольку эта логика теперь лежит в одном месте и вызывающему коду о ней думать не надо.
                0
                Переосмыслив свой пример из статьи, я понял, что действительно провел для себя невидимую грань между Operation и Service, которые на самом деле являются одним слоем. В конце статьи сделал небольшую пометку, касаемо этого вопроса. Спасибо за развернутые комментарии.

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

              Самое читаемое