ruby erb 99 lines · 3 tabs

Shallow Controller, Deep Params: Form Object Pattern

Shared by codesnips Jan 2026
3 tabs
class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :account_name, :string
  attribute :email, :string
  attribute :password, :string
  attribute :plan, :string, default: "free"

  validates :account_name, presence: true, length: { maximum: 60 }
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :plan, inclusion: { in: %w[free pro enterprise] }
  validate :password_complexity

  attr_reader :account, :user

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      @account = Account.create!(name: account_name, plan: plan)
      @user = @account.users.create!(email: email, password: password, role: :owner)
    end
    true
  rescue ActiveRecord::RecordInvalid => e
    promote_errors(e.record)
    false
  end

  private

  def password_complexity
    return if password.blank?
    return if password.match?(/\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}\z/)

    errors.add(:password, "must be 10+ chars with upper, lower, and a digit")
  end

  def promote_errors(record)
    record.errors.each do |error|
      errors.add(error.attribute, error.message)
    end
  end
end
3 files · ruby, erb Explain with highlit

This snippet demonstrates the form object pattern, a way to move multi-model creation logic out of a fat Rails controller and into a dedicated object that speaks ActiveModel. The controller stays shallow — it parses params and dispatches — while a plain Ruby object owns the validation rules, the deep parameter shape, and the persistence that spans several tables.

The SignupForm tab defines the form. It includes ActiveModel::Model and ActiveModel::Attributes so the object behaves like an ActiveRecord model to Rails view helpers and controllers: it responds to valid?, exposes errors, and can be handed to form_with. The attributes here — account_name, email, password, plan — deliberately do not match one table; they span an Account and its owning User. Validations that would otherwise be smeared across two models and a controller live in one place, including a custom password_complexity check. The save method wraps both record writes in a single ActiveRecord::Base.transaction so a half-created account can never leak; if either model fails, promote_errors copies the record-level messages back onto the form so the same view can render them.

The key trade-off is that the form object duplicates a little validation that also exists on the models, but in exchange the controller and the models each stay focused. A form object is the right reach when one HTTP request must create or update several records atomically, or when the accepted params do not cleanly map to a single model.

The SignupsController tab shows the payoff: create builds the form from signup_params, calls save, and branches on the boolean. There is no Account.new, no nested build, no transaction, and no cross-model orchestration in the controller. The signup_params method still uses strong parameters, but permits the flat shape the form expects rather than a deep nested hash.

The new signup form view tab renders the form with form_with model:, and because the object quacks like a model, f.text_field and form.object.errors work unchanged. The password_complexity regex and the promote_errors mapping are the two spots most worth studying, since they are where the pattern earns its keep.


Related snips

Share this code

Here's the card — post it anywhere.

Shallow Controller, Deep Params: Form Object Pattern — share card
Link copied