Вечный спор в среде 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-приложения с использованием паттернов проектирования, который позволяет держать и контроллеры, и модели тонкими.

Что мы получили:

  1. Разделение ответственности — каждый класс выполняет только одну задачу согласно принципу Single Responsibility из SOLID.

  2. Читабельность кода — при взгляде на контроллер сразу понятно, что происходит: парсинг параметров → валидация → выполнение бизнес-логики.

  3. Переиспользование — Query, Validator и Service можно использовать в разных местах приложения.

  4. Простота тестирования — каждый класс легко покрыть тестами изолированно от других компонентов.

  5. Масштабируемость — при росте приложения не нужно бороться с “жирными” моделями на тысячи строк кода.

  6. Гибкость — легко добавлять новую логику без риска сломать существующую функциональность.

Когда использовать этот подход:

  • В средних и крупных приложениях, где важна долгосрочная поддержка кода

  • Когда над проектом работает команда разработчиков

  • Когда бизнес-логика приложения сложная и разнообразная

  • Когда нужна высокая тестовая покрытость

Когда можно обойтись без паттернов:

  • В маленьких pet-проектах или MVP

  • В простых CRUD-приложениях без сложной бизнес-логики

  • Когда скорость разработки важнее архитектуры


Важно помнить, что паттерны — это инструменты, а не догма. Используйте их разумно и по мере необходимости. Не стоит усложнять простой код ради следования паттернам, но и не стоит бояться рефакторинга, когда код начинает разрастаться.

Главное правило: если вы не понимаете, что делает код без отладчика — пора рефакторить.

Надеюсь, эта статья поможет вам сделать ваши Rails-приложения чище, поддерживаемее и приятнее в разработке!


P.S. Если у вас остались вопросы или вы хотите поделиться своим опытом внедрения паттернов — пишите в комментариях. Давайте вместе делать Ruby-сообщество лучше! ?

Комментарии (0)