Паттерн Service Object в Rails

Peter Bazov
5 min readMar 13, 2020

--

Введение

Фреймворк для разработки Web-приложений Ruby On Rails построен на архитектуре MVC, которая хорошо показывает себя в небольших проектах.

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

Проблема 1. Контроллеры и модели разрастаются. Количество строк кода на один файл может вырасти до нескольких тысяч.

Проблема 2. Непонятно, где искать код, в модели или в контроллере.

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

Решение состоит в том, чтобы использовать паттерн “Service Object”. В данной статье будет использоваться перевод “Сервис”.

Описание паттерна

Паттерн “Сервис” подразумевает создание специальных классов, отвечающих за логику в проекте. Вместо того, чтобы реализовывать сложную логику внутри контроллера или модели, вынесите код в сервис.

Пример

Рассмотрим контроллер, в котором есть метод index для получения списка пользователей:

Если в таком контроллере будет реализовано много методов API, каждый из которых имеет сложную логику, то класс станет большим, нечитаемым и трудно тестируемым.

Можно разбить index на несколько методов, но это не решит всех проблем. Лучшим решением будет вынести логику в специальный класс, оставив в контроллере работу с параметрами и создание ответа.

Такой подход решает срезу несколько проблем:

  • Код контроллеров и моделей не будет разрастаться при усложнении логики. Если сервис сам становится перегруженным, его можно разбить на несколько других сервисов.
  • Отсутствует проблема выбора, в какое место поместить код, в контроллер или модель. Ответ один — в сервис. Теперь каждая сущность ответственна за свою собственную задачу. Контроллер решает, что делать. Сервис решает, как делать. Модель предоставляет удобный интерфейс для общения с базой данных.
  • Исходный код сервиса легко использовать повторно, покрывать автоматическими тестами и тестировать вручную через консоль.

Таким же образом внутри сервисов можно скрыть обращение к внешним API и SDK, чтобы их сложности не мешали концентрироваться на реализации бизнес-логики.

Основные принципы

Сервис должен быть объектом

Сервис должен быть экземпляром класса. Это позволит передать в конструктор входные параметры и сохранить их в свойства объекта, чтобы в дальнейшем не передавать их из метода в метод:

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

Сервис должен выполнять только одно действие

В сервисе должен быть только один публичный метод для выполнения строго определенного действия.

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

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

Именование

Публичный метод сервиса можно именовать по-разному, и это дело вкуса. Главное, чтобы именование было едино по всему проекту. Встречаются различные названия: “execute”, “perform”, “call”. Я придерживаюсь названия “call” по простой причине: при отличной смысловой нагрузке оно самое короткое.

Сервисы так же можно именовать по-разному. Один из стилей подразумевает объединение названия действия с суффиксом “Service”, например: “UserCreationService”, “NotificationSendingService”. Другой стиль говорит именовать сервисы существительными: “UserCreator”, “NotificationSender”. Выберите один из них и придерживайтесь его в своём проекте.

Возвращаемое значение

Изучая в сети примеры использования сервисных объектов я увидел несколько вариантов, в каком виде можно возвращать данные из сервиса:

  • Логическое true или false;
  • Код результата;
  • false в случае неудачи и данные в случае успеха;
  • Объект ActiveRecord;
  • Объект результата, включающий в себя всё вышеперечисленное.

Возвращение логического результата делает невозможным получение каких-либо других данных из сервиса. При таком подходе вы обязаны иметь ссылки на все необходимые данные еще до вызова сервиса. Это особенно неудобно при создании новых записей в базе данных — приходится заранее создавать экземпляр модели и передавать его в сервис.

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

Возвращение false в случае ошибки и данных в случае успеха делает затруднительным понять, в связи с чем произошла ошибка.

Если всегда возвращать ActiveRecord, то невозможно написать сервис без участия ActiveRecord. Придется смешивать концепции и в каких-то случаях возвращать не ActiveRecord.

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

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

Лучшие практики

Что не нужно выполнять в сервисе

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

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

Не работайте с интернационализацией в сервисе. Формируйте сообщения для пользователей на уровне контроллера. Иначе может случиться следующее:

  • Сервисы станут сильно привязаны к конкретным контроллерам. Их будет трудно использовать в других контроллерах, а еще сложнее — в других сервисах. Будет затруднительно формировать в разных местах вызова отличающиеся сообщения.
  • Для покрытия сервиса тестами придется производить сравнение строк сообщений. Это обычная практика для тестирования модулей отображения, но не для тестирования бизнес-логики.

Не выносите код коллбэков ActiveRecord в сервис, если он не относится к бизнес-логике. Например:

  • Удаление лишних пробелов в начале и в конце строк.
  • Заполнение вспомогательных полей в таблице, которые нужны для оптимизации запросов.

Пространства имен

В больших проектах стоит разбивать сервисы по пространствам имен, не то будет сложно ориентироваться в одной большой папке с сервисами. Я предпочитаю создавать отдельные папки для каждой сущности, с которой производится работа. Для работы с пользователями — папка “users”. Для работы с уведомлениями — папка “notifications”. Если ваши модели, в свою очередь, тоже разбиты на модули, то сделайте аналогично и с сервисами.

Обработка исключений

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

Реализация

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

--

--