Рассмотрим простой модуль, отвечающий за добавление новых пользователей.

И на его примере увидим, какие возможности открывает применение принципа DRY.

Для меня принцип DRY (Don't Repeat Yourself) всегда воплощался в двух основных определениях:

  1. Дублирование знаний — всегда нарушение принципа
  2. Дублирование кода — не всегда нарушение принципа

Начнем с контроллеров содержащих минимальное количество логики.

class UserController
{
    public function create(CreateRequest $request)
    {
        $user = User::create($request->all());
        
        return view('user.created', compact('user'));
    }
}

class UserApiController
{
    public function create(CreateRequest $request)
    {
        $user = User::create($request->all());
        
        return response()->noContent(201);
    }
}

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

class UserService
{
    public function create(array $data): User
    {
        $user = new User;
        $user->email = $data['email'];
        $user->password = $data['password'];
        $user->save();

        return $user;
    }
    
    public function delete($userId): bool 
    {
        $user = User::findOrFail($userId);
 
        return $user->delete();
    }    
}

Переместив всю логику работы с моделью в сервис, избавляемся от ее дублирования в контроллере. Но у нас появляется другая проблема. Допустим, нам предстоит немного усложнить процесс создания пользователя.

class UserService
{
    protected $blogService;
    
    public function __construct(BlogService $blogService)
    {
        $this->blogService = $blogService;
    }

    public function create(array $data): User
    {
        $user = new User;
        $user->email = $data['email'];
        $user->password = $data['password'];
        $user->save();
        
        $blog = $this->blogService->create();
        $user->blogs()->attach($blog);

        return $user;
    }
    
    //Other methods
}

Постепенно класс UserService начнет разрастаться и мы рискуем получить супер класс с огромным количеством зависимостей.

Класс единого действия CreateUser


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

Основные требования к такому классу:

  • Имя отображающее действие которое предстоит выполнить
  • Имеет единственный публичный метод (я буду использовать магический метод __invoke)
  • Имеет внутри себя все необходимые зависимости
  • Обеспечивает внутри себя соблюдение всех бизнес правил, генерирует исключение при их нарушении

class CreateUser
{
    protected $blogService;

    public function __construct(BlogService $blogService)
    {
        $this->blogService = $blogService;
    }

    public function __invoke(array $data): User
    {
        $email = $data['email'];
        
        if (User::whereEmail($email)->first()) {
            throw new EmailNotUniqueException("$email should be unique!");
        }
        
        $user = new User;
        $user->email = $data['email'];
        $user->password = $data['password'];
        $user->save();

        $blog = $this->blogService->create();
        $user->blogs()->attach($blog);

        return $user;
    }
}

У нас уже есть проверка поля email в классе CreateRequet, но логично добавить проверку и сюда. Это более точно отображает бизнес логику создания пользователя, а также упрощает отладку.

Контроллеры обретают следующий вид

class UserController
{
    public function create(CreateRequest $request, CreateUser $createUser)
    {
        $user = $createUser($request->all());

        return view('user.created', compact('user'));
    }
}

class UserApiController
{
    public function create(CreateRequest $request, CreateUser $createUser)
    {
        $user = $createUser($request->all());

        return response()->noContent(201);
    }
}

В итоге имеем полностью изолированную логику создания пользователя. Ее удобно изменять и расширять.

Теперь посмотрим какие преимущества нам дает такой подход.

Например, есть задача импортировать пользователей.

class ImportUser
{
    protected $createUser;
    
    public function __construct(CreateUser $createUser)
    {
        $this->createUser = $createUser;
    }
    
    public function handle(array $rows): Collection
    {
        return collect($rows)->map(function (array $row) {
            try {
                return $this->createUser($row);
            } catch (EmailNotUniqueException $e) {
                // Deal with duplicate users
            }
        });
    }
}

Получаем возможность повторного использования кода, встраивая его в метод Collection::map(). А так же обработать под наши нужды пользователей, чьи email адреса не являются уникальными.

Декорирование


Допустим нам необходимо регистрировать каждого нового пользователя в файл.

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

class LogCreateUser extends CreateUser 
{
    public function __invoke(array $data)
    {
        Log::info("A new user has registered: " . $data['email']);
        
        parent::__invoke($data);
    }
}

Затем, используя IoC-контейнер Laravel, мы можем связать класс LogCreateUser с классом CreateUser, и первый будет внедрен каждый раз, когда нам понадобиться экземпляр второго.

class AppServiceProvider extends ServiceProvider
{

    // ...

    public function register()
    {
        $this->app->bind(CreateUser::class, LogCreateUser::class);
    }

Мы также имеем возможность вынести настройку создания пользователя с помощью переменной в файле конфигурации.

class AppServiceProvider extends ServiceProvider
{

    // ...

    public function register()
    {
         if (config("users.log_registration")) {
             $this->app->bind(CreateUser::class, LogCreateUser::class);
         }
    }

Вывод


Здесь приведен простой пример. Реальная польза начинает проявляться, как только начинает расти сложность. Мы всегда знаем, что код находится в одном месте и его границы четко определены.

Получаем следующие преимущества: предотвращает дублирование, упрощает тестирование
и открывает дорогу к применению других принципов и паттернов проектирования.