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

Сегодня мы поговорим об одном из способов организации бизнес логики - сервисном слое (он же 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.

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

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