Тонкие контроллеры и модели. Использование паттернов проектирования в Rails-приложении

Страницы:  1

Ответить
 

Professor Seleznov


Вечный спор в среде MVC-фреймворков - что лучше? Толстые модели и тонкие контроллеры или наоборот?
Классический подход Rails — “Fat Model, Skinny Controller”. Но что происходит, когда ваша модель User разрастается до 800 строк кода, содержит 15 валидаций, 10 коллбеков и 30 методов бизнес-логики? Тестировать это становится кошмаром, а понять что и когда вызывается — квестом для детектива.
Сегодня мы рассмотрим альтернативный вариант — тонкие контроллеры и… тонкие модели!
В основе нашего эксперимента будет стоять первое правило SOLID — Single Responsibility Principle (у класса должна быть только одна причина для изменения).
Как правило, модель ответственна за обращения к БД, валидации, коллбеки, пользовательскую бизнес-логику сущностей и так далее. Теперь модель будет отвечать только за связь с БД. Таким же образом мы снимем лишнюю ответственность с контроллеров, раскидаем всё по классам, чтобы код стал читабельнее, красивее и проще в поддержке.
Что не так с классическим подходом?
Давайте посмотрим на типичную модель в Rails-проекте:
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true, format: { with: EMAIL_REGEX }
validates :password, length: { minimum: 8 }
# ... ещё 15 валидаций
before_validation :normalize_email
after_create :send_welcome_email
after_create :create_profile
# ... ещё 10 коллбеков
scope :active, -> { where(active: true) }
scope :premium, -> { where(plan: 'premium') }
# ... ещё 18 scope'ов
def upgrade_to_premium!
# 50 строк кода
end
def calculate_discount
# 30 строк кода
end
# ... ещё 28 методов
end
Проблемы такого подхода:
  • Модель на 800+ строк — никто не понимает что там происходит
  • Коллбеки срабатывают везде — даже когда не нужно
  • Тесты медленные — приходится поднимать всю ActiveRecord
  • Переиспользовать логику сложно — всё завязано на модель
  • Рефакторинг — страшный сон (изменил одно — сломалось в 10 местах)
Как избежать этих проблем?
Давайте рассмотрим пример, в котором мы делаем вывод списка товаров и их создание, а далее по шагам поймём, какие есть проблемы и как их решить, используя паттерны.
# Route
# config/routes.rb
Rails.application.routes.draw do
resources :products
end
# Контроллер
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def index
@products = Product.all
@products = @products.where('name LIKE ?', "%#{search_params[:name]}%") if search_params[:name].present?
@products = @products.where('amount > ?', search_params[:min_amount]) if search_params[:min_amount].present?
@products = @products.where('amount < ?', search_params[:max_amount]) if search_params[:max_amount].present?
@products = @products.order('amount DESC')
end
def new
@product = Product.new
end
def create
@product = Product.create(create_params)
redirect_to products_path
end
private
def search_params
params.permit(:name, :min_amount, :max_amount)
end
def create_params
params.require(:product).permit(:name, :amount)
end
end
# Модель
# app/models/product.rb
class Product < ApplicationRecord
validates :name, presence: true, length: { maximum: 20 }
validates :amount, presence: true, numericality: true
before_validation -> do
# transliterate - вымышленный метод для класса String, который я добавил для примера
self.code = name.transliterate
end
end
# Schema
ActiveRecord::Schema[8.0].define(version: 2025_05_25_000000) do
create_table "products", force: :cascade do |t|
t.string "name"
t.string "code"
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
end
<!-- views/products/index.html.erb -->
<%= link_to('Добавить', new_product_path) %>
<% @products.each do |product| %>
<p>
<%= product.name %> (<%= product.amount %>руб)
</p>
<% end %>
<!-- views/products/new.html.erb -->
<%= form_with model: @product do |f| %>
<%= f.text_field :name %>
<%= f.number_field :amount %>
<%= f.submit %>
<% end %>
-Первая проблема, с которой мы сталкиваемся — контроллер несёт дополнительную ответственность в виде модерации входящих параметров с помощью ActionController::Parameters и require/permit. Глобально это хорошая защита, которую нам даёт Rails, но с увеличением количества методов увеличивается количество проверок, и всё это лежит в самом контроллере.
Вторая проблема — мы не можем точно гарантировать, какие параметры нам придут в контроллер, с каким значением и типом. Например, min_amount может прийти как строка, а может не прийти вовсе. У нас достаточно простой пример, в котором это не критично, но чтобы потом не натолкнуться на undefined method for an instance of Integer в будущем, лучше сразу подготовиться и использовать следующий паттерн:
Паттерн ActionParams
Цель: более тонкий контроль параметров контроллера.
Реализация:
# app/core/action_params/products/index.rb
module ActionParams
module Products
class Index
def call(params)
{
name: params[:name].presence.try(:to_s),
min_amount: params[:min_amount].presence.try(:to_i),
max_amount: params[:max_amount].presence.try(:to_i)
}
end
end
end
end
# app/core/action_params/products/create.rb
module ActionParams
module Products
class Create
def call(params)
{
name: params[:name].presence.try(:to_s),
amount: params[:amount].presence.try(:to_i),
}
end
end
end
end
Важно! Для наших новых классов мы создали папку app/core. Внутри этой директории мы создаём папки по названию паттернов. В данном случае это action_params. Мы это делаем для удобства вызова классов, чтобы первый модуль всегда был по названию паттерна.
Использование:
class ProductsController < ApplicationController
def index
action_params = ::ActionParams::Products::Index.new.call(params)
@products = Product.all
@products = @products.where('name LIKE ?', "%#{action_params[:name]}%") if action_params[:name].present?
@products = @products.where('amount > ?', action_params[:min_amount]) if action_params[:min_amount].present?
@products = @products.where('amount < ?', action_params[:max_amount]) if action_params[:max_amount].present?
@products = @products.order('amount DESC')
end
def new
@product = Product.new
end
def create
action_params = ::ActionParams::Products::Create.new.call(params[:product])
@product = Product.create(action_params)
redirect_to products_path
end
end
Преимущества:
  • Не создаём в контроллере дополнительные проверки параметров require/permit;
  • Мы точно понимаем, какие параметры будут использоваться дальше в коде;
  • Мы точно понимаем, какого типа будут параметры. Например, name будет либо nil, либо string и никак иначе.
  • Легко тестировать — это обычный Ruby-класс без привязки к Rails.
-Следующая проблема, с которой мы сталкиваемся — большое количество фильтраций модели Product внутри контроллера, которые мы также вынесем в отдельный класс.
Паттерн Query
Цель: инкапсуляция запросов в базу данных
Реализация
# app/core/queries/products/list.rb
module Queries
module Products
class List
def call(params)
products = Product.all
products = name_filter(products, params[:name])
products = amount_range(products, params[:min_amount], params[:max_amount])
products = sorting(products)
products
end
private
def name_filter(relation, name)
return relation if name.blank?
relation.where('name LIKE ?', "%#{name}%")
end
def amount_range(relation, min, max)
relation = relation.where('amount > ?', min) if min.present?
relation = relation.where('amount < ?', max) if max.present?
relation
end
def sorting(relation)
relation.order('amount DESC')
end
end
end
end
Использование
class ProductsController < ApplicationController
def index
action_params = ::ActionParams::Products::Index.new.call(params)
@products = Queries::Products::List.new.call(action_params)
end
end
Преимущества
  • При большом количестве фильтраций контроллер разрастётся как муравейник, и его будет неудобно поддерживать. Теперь мы видим только одну строчку вызова Query.
  • Класс Query можно повторно использовать. Например, товары можно отфильтровать на детальной странице для раздела “Рекомендации”.
  • Инкапсуляция логики Active Record. Внутри Query не обязательно использовать логику Rails. Мы можем обращаться к Redis, json-файлу на диске и всё что угодно, главное, чтобы на входе были параметры фильтрации, а на выходе — объекты модели или другие структуры данных.
-С index-ом разобрались, далее create. Что тут не так? Вроде одна строчка кода и ничего сложного? Но проблема в том, что часть логики лежит в модели. Сейчас она отвечает за валидацию и транслитерацию кода. Соответственно, при увеличении количества валидаций и дополнительных обработок данных модель будет разрастаться, и её станет тяжело поддерживать.
Как вы можете уже догадаться, валидацию мы вынесем в отдельный класс.
Паттерн Validation
Цель: инкапсуляция валидации входящих данных перед сохранением
Реализация
# app/core/validators/base.rb
module Validators
class Base
include ActiveModel::Validations
include ActiveModel::AttributeAssignment
def initialize(params)
assign_attributes(params || {})
end
def call
raise ActiveRecord::RecordInvalid.new(self) unless valid?
end
end
end
# app/core/validators/products/record.rb
module Validators
module Products
class Record < Base
attr_accessor :name, :amount
validates :name, presence: true, length: { maximum: 20 }
validates :amount, presence: true, numericality: true
end
end
end
Обратитете внимание на класс base.rb. Мы сюда вынесли общую логику, чтобы её не прописывать каждый раз для каждого валидатора.
Что делает этот класс:
  • Наследуется от Rails-валидатора
  • В initialize делает ассоциацию параметров класса и параметров, которые пришли в валидатор. Обратитете внимание, что они должны совпадать.
  • В call вызываем ошибку, если параметры не валидны.
Что мы описываем в основном классе валидации:
  • В attr_accessor перечисляем параметры, которые придут в валидатор и которые мы будем проверять. Именно эти параметры проассоциируются методом assign_attributes
  • Дальше, как мы и привыкли в модели, описываем наши валидации. Можно просто перенести из модели как они есть.
Использование
class ProductsController < ApplicationController
def create
action_params = ::ActionParams::Products::Create.new.call(params[:product])
::Validators::Products::Record.new(action_params).call
@product = Product.create(action_params)
end
end
Обратитете внимание, что если в валидатор передать ключи, которых нет в attr_accessor, то метод assign_attributes упадёт с ошибкой. Но мы эту проблему избегаем, так как в ActionParams собираем именно те ключи, которые необходимо отдать в валидатор.
Преимущества
  • Вынесли валидации из модели
  • Можно уйти от валидаций, предоставляемых Rails, написать собственные проверки и кастомизировать валидации.
  • Можно проверять не только значения полей модели, но и любые другие параметры.
-И последняя проблема, которую мы можем заметить — создание товара в контроллере и обработка коллбека в самой модели. Тут всё решается очень просто: любую бизнес-логику можно вынести в сервис.
Паттерн Service
Цель: инкапсуляция бизнес-логики
Реализация
# app/core/services/products/create.rb
module Services
module Products
class Create
def call
Product.create!(prepare_params(params))
end
private
def prepare_params(params)
params[:code] = params[:name].transliterate
end
end
end
end
Использование
class ProductsController < ApplicationController
def index
action_params = ::ActionParams::Products::Index.new.call(params)
@products = Queries::Products::List.new.call(action_params)
end
def new
@product = Product.new
end
def create
action_params = ::ActionParams::Products::Create.new.call(params[:product])
::Validators::Products::Record.new(action_params).call
@product = Services::Products::Create.call(action_params)
redirect_to products_path
end
end
Преимущества
  • Вся бизнес-логика в одном месте. Коллбеки поддерживать очень сложно — при каждом взаимодействии с моделью в коде нужно идти в саму модель и смотреть, есть ли коллбеки. Теперь мы всю логику реализуем в одном месте. И если коллбек не нужен в другом контексте, то мы его не используем.
  • Переиспользование логики. Сервис можно вызвать из разных мест приложения: из контроллера, фоновой задачи, rake-таски, консоли.
  • Простота тестирования. Сервис — это обычный Ruby-класс, который легко покрыть unit-тестами.
  • Гибкость. Можно легко добавить дополнительную логику, например, интеграцию с внешними API, отправку уведомлений, логирование и т.д.

-
Итоговый результат
Теперь давайте посмотрим, как выглядит наш код после всего рефакторинга:
Модель
# app/models/product.rb
class Product < ApplicationRecord
# Модель отвечает только за связь с БД
# Никаких валидаций, коллбеков и бизнес-логики
end
Контроллер
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def index
action_params = ::ActionParams::Products::Index.new.call(params)
@products = Queries::Products::List.new.call(action_params)
end
def new
@product = Product.new
end
def create
action_params = ::ActionParams::Products::Create.new.call(params[:product])
::Validators::Products::Record.new(action_params).call
@product = Services::Products::Create.call(action_params)
redirect_to products_path
end
end
Структура директорий
app/
├── controllers/
│ └── products_controller.rb
├── models/
│ └── product.rb
└── core/
├── action_params/
│ └── products/
│ ├── index.rb
│ └── create.rb
├── queries/
│ └── products/
│ └── list.rb
├── validators/
│ ├── base.rb
│ └── products/
│ └── record.rb
└── services/
├── base.rb
└── products/
└── create.rb

-
Заключение
Мы рассмотрели подход к построению Rails-приложения с использованием паттернов проектирования, который позволяет держать и контроллеры, и модели тонкими.
Что мы получили:
  • Разделение ответственности — каждый класс выполняет только одну задачу согласно принципу Single Responsibility из SOLID.
  • Читабельность кода — при взгляде на контроллер сразу понятно, что происходит: парсинг параметров → валидация → выполнение бизнес-логики.
  • Переиспользование — Query, Validator и Service можно использовать в разных местах приложения.
  • Простота тестирования — каждый класс легко покрыть тестами изолированно от других компонентов.
  • Масштабируемость — при росте приложения не нужно бороться с “жирными” моделями на тысячи строк кода.
  • Гибкость — легко добавлять новую логику без риска сломать существующую функциональность.
Когда использовать этот подход:
  • В средних и крупных приложениях, где важна долгосрочная поддержка кода
  • Когда над проектом работает команда разработчиков
  • Когда бизнес-логика приложения сложная и разнообразная
  • Когда нужна высокая тестовая покрытость
Когда можно обойтись без паттернов:
  • В маленьких pet-проектах или MVP
  • В простых CRUD-приложениях без сложной бизнес-логики
  • Когда скорость разработки важнее архитектуры
-Важно помнить, что паттерны — это инструменты, а не догма. Используйте их разумно и по мере необходимости. Не стоит усложнять простой код ради следования паттернам, но и не стоит бояться рефакторинга, когда код начинает разрастаться.
Главное правило: если вы не понимаете, что делает код без отладчика — пора рефакторить.
Надеюсь, эта статья поможет вам сделать ваши Rails-приложения чище, поддерживаемее и приятнее в разработке!-P.S. Если у вас остались вопросы или вы хотите поделиться своим опытом внедрения паттернов — пишите в комментариях. Давайте вместе делать Ruby-сообщество лучше! 🚀-Источник
 
Loading...
Error