How I Use Form Objects to Keep Rails Controllers Clean

by Eric Hanson, Backend Developer at Clean Systems Consulting

Where Rails form handling breaks down

Rails' default assumption is that a form maps to one model. form_with model: @user submits to UsersController, strong_parameters permits the attributes, @user.update(params) saves. Clean, minimal, correct — for simple cases.

The assumption breaks in three specific situations:

One form creates or updates multiple models. A registration form that creates a User and a Workspace simultaneously. A checkout form that creates an Order, Address, and PaymentMethod at once.

The form accepts data that doesn't map to columns. A "change password" form has current_password, new_password, and new_password_confirmation — none of which are stored columns. Putting validation for these on the model means the model knows about the form's shape.

The validation logic is contextual. The same User model might have different validation requirements depending on whether you're in admin-mode creation, self-service registration, or a partial profile update. Shoving all three contexts into one model with conditional validations is how you get validates :phone, presence: true, if: :registration_flow? alongside a registration_flow boolean that has no business being on the model.

Form objects handle all three.

The basic structure

A form object is a plain Ruby class that accepts parameters, validates them, and exposes an interface Rails' form helpers understand. The minimum required interface: model_name (for routing), valid?, errors, and the attribute accessors your form needs.

ActiveModel::Model provides all of this for free:

class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email,                    :string
  attribute :password,                 :string
  attribute :password_confirmation,    :string
  attribute :workspace_name,           :string

  validates :email,             presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password,          presence: true, length: { minimum: 12 }
  validates :workspace_name,    presence: true, length: { maximum: 60 }
  validate  :password_confirmation_matches

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      user = User.create!(email: email, password: password)
      Workspace.create!(name: workspace_name, owner: user)
    end

    true
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.message)
    false
  end

  private

  def password_confirmation_matches
    return if password == password_confirmation
    errors.add(:password_confirmation, "does not match password")
  end
end

ActiveModel::Attributes (distinct from ActiveModel::Model) gives you typed attributes with coercion — :string strips leading/trailing whitespace, :integer converts string params, :boolean handles Rails' checkbox conventions. Without it, all params arrive as strings and you handle coercion manually.

Controller usage

The controller becomes an HTTP adapter with no business logic:

class RegistrationsController < ApplicationController
  def new
    @form = RegistrationForm.new
  end

  def create
    @form = RegistrationForm.new(registration_params)

    if @form.save
      redirect_to dashboard_path, notice: "Welcome!"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def registration_params
    params.require(:registration).permit(:email, :password, :password_confirmation, :workspace_name)
  end
end

No User.new, no Workspace.new, no transaction management, no cross-model orchestration. The controller asks the form object if it can save, and responds based on the result.

View integration

form_with model: @form works because ActiveModel::Model implements model_name. For a form object named RegistrationForm, Rails infers the route and form key automatically. If you need a custom route:

form_with model: @form, url: registrations_path, method: :post do |f|
  f.email_field :email
  f.password_field :password
  f.password_field :password_confirmation
  f.text_field :workspace_name
  f.submit "Create Account"
end

Error display works identically to model-backed forms — @form.errors is populated by valid?, and f.label, f.text_field etc. pick up error state automatically.

Separating validation from persistence

The save pattern above couples validation and persistence in one method. For more complex form objects, or when you need to call the form from a service object rather than a controller, separate them:

class ChangePasswordForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :current_password,          :string
  attribute :new_password,              :string
  attribute :new_password_confirmation, :string

  attr_reader :user

  validates :current_password,          presence: true
  validates :new_password,              presence: true, length: { minimum: 12 }
  validate  :current_password_correct
  validate  :confirmation_matches

  def initialize(user:, **attrs)
    @user = user
    super(**attrs)
  end

  private

  def current_password_correct
    return if user.authenticate(current_password)
    errors.add(:current_password, "is incorrect")
  end

  def confirmation_matches
    return if new_password == new_password_confirmation
    errors.add(:new_password_confirmation, "does not match")
  end
end

The controller calls form.valid? then passes the validated form to a service object:

def update
  @form = ChangePasswordForm.new(user: current_user, **password_params)

  if @form.valid?
    UpdatePassword.call(user: current_user, new_password: @form.new_password)
    redirect_to account_path, notice: "Password updated"
  else
    render :edit, status: :unprocessable_entity
  end
end

The form object validates the input. The service object handles the persistence and any side effects (session invalidation, notification email). Neither bleeds into the other's responsibility.

Contextual validations without model pollution

The validation-context problem — different rules for different flows — is where form objects pay off most clearly. Instead of one User model with twelve conditional validations:

# Admin creating a user — email required, password optional (sent by email)
class AdminUserCreationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :role,  :string

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :role,  inclusion: { in: %w[member admin viewer] }
end

# Self-service registration — password required, role not settable
class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email,    :string
  attribute :password, :string

  validates :email,    presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, presence: true, length: { minimum: 12 }
end

Two forms, two validation contexts, zero conditional logic on the model. The User model validates only what must always be true — email uniqueness at the database level, presence of whatever the application can't function without. Form-specific constraints live in the form.

Testing

Form objects test without a database for the validation layer:

RSpec.describe RegistrationForm do
  subject(:form) { described_class.new(attrs) }

  let(:attrs) do
    {
      email:                 "alice@example.com",
      password:              "correct-horse-battery-staple",
      password_confirmation: "correct-horse-battery-staple",
      workspace_name:        "Acme Corp"
    }
  end

  it "is valid with all required attributes" do
    expect(form).to be_valid
  end

  context "when password confirmation does not match" do
    let(:attrs) { super().merge(password_confirmation: "wrong") }

    it "is invalid" do
      expect(form).not_to be_valid
      expect(form.errors[:password_confirmation]).to include("does not match password")
    end
  end
end

No create(:user), no database transactions, no ActiveRecord setup. The validation tests run in milliseconds. Persistence tests — that save actually creates the records — belong in a separate integration spec with a database.

When not to use them

Form objects earn their weight when the form has complexity — multiple models, virtual attributes, contextual validations, or non-trivial cross-field validation. For a simple Profile edit that updates four columns on one model with standard presence/format validations, a form object is pure ceremony. The model's own validations and update handle it correctly.

The signal is whether the controller would otherwise need conditional logic, multi-model coordination, or virtual attributes. If the controller action is clean without a form object, the model is doing its job. Introduce a form object when the controller starts accumulating complexity that clearly belongs in the input layer, not the persistence layer.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Your Code Just Crashed the Client’s Server—Now What?

Panic sets in, emails start flying, and your stomach drops. A crash happened, but it’s not the end of the world—you can handle this.

Read more

Why English-First Async Contractors Are the Practical Answer for Tokyo Tech Startups

Building a backend team in Tokyo is slow and expensive. Some founders have found a working alternative that doesn't require solving the local market first.

Read more

What a Spring Controller Should and Shouldn't Do — A Practical Boundary Guide

Spring controllers accumulate logic because they're the most visible layer and the easiest place to add code. The result is controllers that are hard to test, hard to reuse, and hard to change. Here is a clear boundary that scales.

Read more

What It Actually Costs to Hire a Senior Backend Developer in Sydney

You budgeted $160K for a senior backend hire. Then you saw what they actually cost once super, recruiter fees, and three months of low output were factored in.

Read more