Вечный спор в среде 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-сообщество лучше! ?