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.