Rails Form Objects: When To Use Them

Learn when to use Rails Form Objects, how to build them with ActiveModel::Model, and when a simple model is enough.


By Jean Emmanuel Cadet • 28 min read

Rails Form Objects: When to Use Them
Share with friends

Picture a sign-up form that looks simple on the page: email, password, password confirmation, display name, time zone, and a checkbox to accept the terms of service. Behind that form you have a User record, a Profile record, and a few rules that exist only because of this particular screen. The terms checkbox is not a database column, and neither is the password confirmation.

The first instinct is to push everything into User: virtual attributes, conditional validations, accepts_nested_attributes_for, and a callback or two. It works at first, but the model slowly becomes a record of every form that ever touched it.

Rails Form Objects give you a cleaner option. A Form Object is a plain Ruby class that represents one form: its inputs, its validations, and the steps needed to save it. In this guide you will learn what Ruby on Rails Form Objects are, how to build one with ActiveModel::Model, how to save multiple models safely, how to test the result, and, just as important, when you should skip the pattern and keep using a regular Active Record model.


The Problem: One Form, Several Models

Rails is excellent at forms that map directly to a single model. You call form_with model: @post, permit some parameters, and Post.new(post_params) does the rest. Validation errors live on the model, and the form knows how to display them.

Trouble starts when the form stops matching a single table. Common examples:

  • A registration form that creates a User and a Profile together.
  • A checkout form that collects an email, a shipping address, and payment details that end up in an Order, an Address, and a Customer.
  • An onboarding wizard step that needs validation rules that do not apply anywhere else in the app.
  • A search form with filters (price range, category, in-stock only) that never touch the database as attributes.

Why a fat model gets harder to maintain

Without a dedicated place for form logic, developers usually reach for one of these workarounds:

  • Adding attr_accessor virtual attributes to the model, such as terms_accepted or password_confirmation.
  • Writing validations with if: :registering? conditions so they only run in a certain workflow.
  • Using Active Record callbacks to create related records after the main record saves.
  • Creating child records in the controller and hoping nothing fails halfway through.

Each workaround is reasonable on its own. Together they make User responsible for the sign-up screen, the admin screen, the API, and the importer. A validation that only matters for one form now runs, or is conditionally skipped, everywhere. When you change the sign-up flow, you risk breaking something that has nothing to do with sign-up.

A Form Object moves the form-specific parts into a class that exists for one purpose. The Active Record models keep the rules that are always true about your data, and the Form Object owns the rules that are true about this input.


What Is a Rails Form Object?

A Form Object is a Ruby class that wraps the input of a form. It declares the attributes the form accepts, validates them, and exposes a method (usually save) that turns valid input into database changes. To the view and controller, it looks and behaves a lot like a model, so form_with, labels, and error messages work with very little ceremony.

Two things are worth stating clearly before we write any code.

First, Form Objects are a design pattern, not a built-in Rails feature. Rails does not ship a FormObject base class, and there is no single correct implementation. What Rails does provide is ActiveModel, the part of Rails that gives plain Ruby classes validations, naming, and form compatibility. Most Form Objects are built on top of it.

Second, a Form Object does not persist anything on its own. Unlike an Active Record model, it has no table behind it. If it saves data, that is because you wrote a method that tells other objects to save themselves.

How a Form Object differs from an Active Record model

An Active Record model represents a database row. Its attributes come from table columns, it knows how to load and persist itself, and its validations protect the integrity of stored data.

A Form Object represents an interaction. Its attributes come from what the user can type into a form, and some of them never reach the database (a terms checkbox, a password confirmation, a search keyword). Its validations describe what is acceptable for this workflow, not what is acceptable for the data forever.

Here is a useful rule of thumb. If a rule must hold no matter how a record is created (console, API, background job, admin panel), it belongs on the model. If a rule only matters because a person is filling out a specific form, it is a candidate for a Form Object.

How a Form Object differs from an ordinary Ruby object

You could write a plain Ruby class with attr_accessor and an initialize method and call it a form object. The problem is that Rails forms expect certain behavior from the object you pass to form_with: it should respond to attribute readers, expose an errors object, provide a model name for parameter scoping, and answer persisted? so Rails knows whether to use POST or PATCH.

A Form Object includes ActiveModel::Model to get all of that for free. It is still a plain Ruby class (no database, no inheritance from ApplicationRecord), but it speaks the same language as a model as far as Rails helpers are concerned.


Building Blocks: ActiveModel::Model and ActiveModel::Attributes

Two modules do most of the heavy lifting.

ActiveModel::Model

Including ActiveModel::Model gives your class model-like behavior:

  • Mass assignment in the constructor: RegistrationForm.new(email_address: "[email protected]") assigns values through setter methods.
  • Validations: validates, validate, valid?, and an errors collection, the same API you already use in Active Record.
  • Naming and conversion: model_name, to_key, to_param, and persisted? (which returns false by default). These are what form_with and URL helpers rely on.
  • Translation support: error messages and attribute names go through Rails’ i18n system, so errors.full_messages produces readable text.

What it does not give you is typed attributes or database access. It is deliberately minimal.

ActiveModel::Attributes

ActiveModel::Attributes adds an attribute class method that works like the attributes API in Active Record. You declare a name and a type, and Rails casts incoming values for you. That matters for forms because everything arrives from the browser as a string. With attribute :terms_accepted, :boolean, the string "1" becomes true and "0" becomes false. With attribute :min_price, :decimal, "19.99" becomes a BigDecimal.

You can also provide defaults (default: "UTC") and read all values with attributes or self.class.attribute_names.

Use ActiveModel::Attributes when you need type casting or defaults, which is most of the time with real forms. If a form only has a couple of string fields, plain attr_accessor works fine. Both modules are available in every modern Rails version, including Rails 8.


Building a Registration Form Object

Let’s build a realistic example: registering a user and creating the associated profile in one step. The code targets Rails 8 conventions.

The Active Record models

Start with the schema. Here is the migration:

class CreateUsersAndProfiles < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true

create_table :profiles do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.string :display_name, null: false
t.string :time_zone, null: false, default: "UTC"
t.timestamps
end
end
end

And the models:

class User < ApplicationRecord
has_secure_password

has_one :profile, dependent: :destroy

normalizes :email_address, with: ->(email) { email.strip.downcase }

validates :email_address, presence: true, uniqueness: true
end
class Profile < ApplicationRecord
belongs_to :user

validates :display_name, presence: true
end

These models describe what is always true about the data: a user needs a unique email address and a password digest, and a profile belongs to a user and has a display name. They know nothing about terms of service or password confirmation screens. Note that normalizes (available since Rails 7.1) cleans the email before it is saved and queried.

The Form Object

Create the class in app/forms/registration_form.rb. Rails autoloads every directory under app, so the app/forms folder works without extra configuration.

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

attribute :email_address, :string
attribute :password, :string
attribute :password_confirmation, :string
attribute :display_name, :string
attribute :time_zone, :string, default: "UTC"
attribute :terms_accepted, :boolean, default: false

validates :email_address, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true,
length: { minimum: 12 },
confirmation: true
validates :display_name, presence: true, length: { maximum: 50 }
validates :time_zone, inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) }
validates :terms_accepted, acceptance: true
validate :password_must_not_contain_email_name

attr_reader :user

def save
return false unless valid?

ActiveRecord::Base.transaction do
@user = User.create!(
email_address: email_address,
password: password,
password_confirmation: password_confirmation
)
Profile.create!(
user: @user,
display_name: display_name,
time_zone: time_zone
)
end

true
rescue ActiveRecord::RecordInvalid => error
copy_errors_from(error.record)
false
rescue ActiveRecord::RecordNotUnique
errors.add(:email_address, "has already been taken")
false
end

private

def password_must_not_contain_email_name
return if password.blank? || email_address.blank?

name = email_address.split("@").first.to_s
return if name.length < 4

if password.downcase.include?(name.downcase)
errors.add(:password, "must not contain the first part of your email address")
end
end

def copy_errors_from(record)
record.errors.each do |error|
attribute = self.class.attribute_names.include?(error.attribute.to_s) ? error.attribute : :base
errors.add(attribute, error.message)
end

errors.add(:base, "We could not complete your registration") if errors.empty?
end
end

There is a lot here, so let’s walk through the pieces.

Attributes. Every field the form accepts is declared once, with a type. The terms_accepted attribute and the password_confirmation attribute exist purely for this workflow, which is exactly the kind of input that clutters a model.

Validations. The form validates what it owns: email presence and format, password length and confirmation, the display name, a valid time zone, and the acceptance checkbox. The custom password_must_not_contain_email_name method is a good example of a form-specific rule. Nothing about the users table cares whether a password resembles an email address, but your sign-up screen might.

The save method. It follows a simple shape: return false if the input is invalid, otherwise open a transaction, create the records, and return true. We use create! (the bang version) inside the transaction so any failure raises an exception. That exception rolls back every change made inside the block, which keeps the users and profiles tables consistent.

Failure handling. We rescue ActiveRecord::RecordInvalid, which means a model rejected the data even though the form accepted it, and ActiveRecord::RecordNotUnique, which covers the race condition where two people register with the same email at the same moment. The unique index we added in the migration is what makes that second case reliable.

A note on the design trade-off: the form duplicates a couple of checks the models also perform (such as a present email address). That is intentional here, because validating inputs up front lets the user see every problem at once. The models remain the final safety net. What the form does not duplicate is the uniqueness check. That rule depends on database state, so we let the model and the unique index enforce it and then surface the result.

Routes, controller, and strong parameters

Add a singular resource, since a visitor only ever has one registration in progress:

Rails.application.routes.draw do
resource :registration, only: %i[new create]
end

Then the controller:

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

def create
@registration_form = RegistrationForm.new(registration_params)

if @registration_form.save
redirect_to root_path, notice: "Welcome, your account has been created."
else
render :new, status: :unprocessable_entity
end
end

private

def registration_params
params.expect(
registration_form: %i[
email_address password password_confirmation
display_name time_zone terms_accepted
]
)
end
end

Strong parameters matter even though there is no Model.new(params) call to protect. ActiveModel::Model assigns attributes through setters, so passing unpermitted parameters would raise an error, and permitting the wrong keys would let users submit things you never intended. The params.expect method (introduced in Rails 8) requires the registration_form key and returns a permitted hash with only the listed fields.

The parameter key comes from the class name: RegistrationForm becomes registration_form. If you prefer a shorter key, you can override model_name, but the default is fine and keeps things predictable.

Notice the controller stays thin. It builds the form, asks it to save, and chooses between a redirect and a re-render. It does not know that two tables are involved. When the save fails, render :new, status: :unprocessable_entity re-renders the form with a 422 status, which Turbo requires in order to display the updated HTML.

The form view

<%= form_with model: @registration_form, url: registration_path do |form| %>
<% if @registration_form.errors.any? %>
<div role="alert" class="form-errors">
<h2><%= pluralize(@registration_form.errors.count, "error") %> prevented your registration:</h2>
<ul>
<% @registration_form.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<div>
<%= form.label :email_address %>
<%= form.email_field :email_address, autocomplete: "email", required: true %>
</div>

<div>
<%= form.label :password %>
<%= form.password_field :password, autocomplete: "new-password", required: true %>
</div>

<div>
<%= form.label :password_confirmation, "Confirm password" %>
<%= form.password_field :password_confirmation, autocomplete: "new-password", required: true %>
</div>

<div>
<%= form.label :display_name %>
<%= form.text_field :display_name, required: true %>
</div>

<div>
<%= form.label :time_zone %>
<%= form.time_zone_select :time_zone %>
</div>

<div>
<%= form.check_box :terms_accepted %>
<%= form.label :terms_accepted, "I accept the terms of service" %>
</div>

<%= form.submit "Create account" %>
<% end %>

The url: option is required here. When you pass a model to form_with, Rails normally infers the URL from the model’s class (for a Post, that means posts_path). A Form Object has no matching resource, so we tell Rails where to post. Because persisted? returns false, Rails uses POST automatically.

The complete request flow

Putting it together, here is what happens when someone submits the form:

  1. The browser sends a POST to /registration with parameters nested under registration_form.
  2. RegistrationsController#create permits those parameters and builds a RegistrationForm. Type casting turns the checkbox string into a boolean.
  3. save calls valid?. If any form-level validation fails, it returns false and nothing touches the database.
  4. If the input is valid, save opens a transaction, creates the User, then creates the Profile.
  5. If both succeed, the transaction commits, save returns true, and the controller redirects.
  6. If a model rejects the data or the unique index fires, the transaction rolls back, the error is copied onto the form, and the controller re-renders new with a 422 status.

At the database level, a successful run looks like this:

BEGIN;
INSERT INTO users (email_address, password_digest, created_at, updated_at)
VALUES ('[email protected]', '<bcrypt digest>', NOW(), NOW()) RETURNING id;
INSERT INTO profiles (user_id, display_name, time_zone, created_at, updated_at)
VALUES (1, 'Ada Lovelace', 'UTC', NOW(), NOW());
COMMIT;

If the second insert fails, the database issues a ROLLBACK instead of COMMIT, and the user row disappears as if it was never created. That is the guarantee you want.


Validation and Errors in Depth

Validation in a Form Object works the same way it does in a model, because ActiveModel::Validations is the shared foundation. Everything you know carries over.

Attribute validations and custom methods

Built-in validators like presence, length, format, inclusion, confirmation, and acceptance all work on form attributes. When a rule involves more than one attribute or some custom logic, write a method and register it with validate, as we did with password_must_not_contain_email_name. Inside it, add messages with errors.add(:attribute, "message"), or errors.add(:base, "message") for problems that do not belong to a single field.

Calling valid? and handling a failed save

Calling valid? runs every validation and fills errors. It returns true or false. Our save method calls it first and returns early, so invalid input never reaches the database. The controller only needs to check the return value of save.

Exposing errors from underlying models

This is the part developers most often get wrong. When a form creates a User and a Profile, those are separate objects with their own errors. Nothing copies those errors onto your Form Object automatically. If User.create! raises because of a validation failure and you do nothing else, the form’s errors stays empty, save returns false (or worse, the exception escapes), and the user sees a form that silently fails.

The approach in our copy_errors_from method is straightforward and explicit:

  1. Rescue ActiveRecord::RecordInvalid, which carries the failing record in error.record.
  2. Loop through that record’s errors.
  3. Add each one to the Form Object, mapping it to the matching form attribute when the names line up, or to :base when they do not (for example, an error on Profile#user).
  4. Add a generic base error if nothing was copied, so the user always sees feedback.

Because the form attribute names match the model column names here, the mapping is automatic. If your form uses different names (say name in the form and display_name on the model), add a small hash that translates between them.

Displaying errors in the view

The view above lists errors.full_messages at the top, which is a good default. For inline messages next to a field, read the errors for that attribute:

<div>
<%= form.label :email_address %>
<%= form.email_field :email_address, aria: { invalid: @registration_form.errors.include?(:email_address) } %>
<% @registration_form.errors.full_messages_for(:email_address).each do |message| %>
<p class="field-error"><%= message %></p>
<% end %>
</div>

Because the Form Object implements the same errors API as a model, form helpers and any error-display partials you already have should work with it unchanged.


Saving Multiple Models Safely

Coordinating several records is where Form Objects earn their keep, so it is worth being precise about how to do it well.

When a transaction is appropriate

Use a database transaction whenever several writes must succeed or fail together. A user without a profile, or an order without its line items, is a broken state your application then has to defend against everywhere. Wrapping the writes in ActiveRecord::Base.transaction makes them atomic: either every change is committed or none are. For a deeper look at how transactions behave, including nesting and rollbacks, see this practical guide to Rails transactions.

Why bang methods matter

Inside a transaction, prefer create!, update!, and save!. A non-bang method like save returns false on failure instead of raising, which means the transaction block finishes normally and commits whatever succeeded before. That is how you end up with a half-saved registration. Raising an exception is what triggers the rollback.

Transactions do not undo external side effects

A transaction protects the database, and only the database. If you send a welcome email, enqueue a webhook, or call a payment API inside the transaction block and a later step fails, the database changes roll back but the email is already gone.

The safer pattern is to keep side effects out of the transaction entirely and run them after save succeeds. In the controller, that looks like this:

if @registration_form.save
UserMailer.welcome(@registration_form.user).deliver_later
redirect_to root_path, notice: "Welcome, your account has been created."
else
render :new, status: :unprocessable_entity
end

(UserMailer is an example mailer you would write yourself.) This is also why the Form Object exposes user through attr_reader: the caller can use the created record after a successful save.

Keep save focused

It is tempting to keep adding steps to save: create the account, subscribe to a newsletter, notify the sales team, grant a trial. Resist that. A save method should persist the records that belong to this form. Unrelated business processes belong in their own objects, which we will cover shortly.


Form Objects and Rails Forms

Form Objects are designed to feel like models to Rails helpers, but a few differences are worth knowing.

What stays the same

form_with, label, field helpers, errors, and re-rendering on failure all work as they do with an Active Record model. Field helpers read values by calling methods on the object, so form.text_field :display_name calls display_name on your Form Object. When validation fails and you re-render, the submitted values are still there because the form object holds them in memory.

Password fields are the exception, by design. password_field does not re-populate its value, so users retype passwords after a failed submission.

What is different

  • The URL is explicit. A model usually lets Rails infer the route. A Form Object has no matching resource, so pass url:.
  • The parameter key follows the class name. Expect registration_form, not user, and permit parameters accordingly.
  • Persistence state is yours to define. ActiveModel::Model returns false from persisted?. For a create form, that is correct. For an update form, you should return true so Rails uses PATCH, or pass method: :patch explicitly.

An update workflow

Here is an account settings form that updates the same User and Profile:

class AccountSettingsForm
include ActiveModel::Model
include ActiveModel::Attributes

attribute :email_address, :string
attribute :display_name, :string

validates :email_address, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :display_name, presence: true, length: { maximum: 50 }

attr_reader :user

def initialize(user, attributes = {})
@user = user
super(
email_address: user.email_address,
display_name: user.profile.display_name
)
assign_attributes(attributes)
end

def persisted?
true
end

def save
return false unless valid?

ActiveRecord::Base.transaction do
user.update!(email_address: email_address)
user.profile.update!(display_name: display_name)
end

true
rescue ActiveRecord::RecordInvalid => error
copy_errors_from(error.record)
false
end

private

def copy_errors_from(record)
record.errors.each do |error|
attribute = self.class.attribute_names.include?(error.attribute.to_s) ? error.attribute : :base
errors.add(attribute, error.message)
end

errors.add(:base, "We could not update your account") if errors.empty?
end
end

The form is initialized from an existing user, so the fields start with current values, and assign_attributes applies whatever the user submitted. The controller mirrors the registration one:

class AccountsController < ApplicationController
def edit
@account_form = AccountSettingsForm.new(current_user)
end

def update
@account_form = AccountSettingsForm.new(current_user, account_params)

if @account_form.save
redirect_to edit_account_path, notice: "Account updated."
else
render :edit, status: :unprocessable_entity
end
end

private

def account_params
params.expect(account_settings_form: %i[email_address display_name])
end
end

Here current_user stands for whatever authentication helper your app provides. The view uses form_with model: @account_form, url: account_path, method: :patch. If you end up with several forms repeating copy_errors_from, extract it into a small concern, but wait until the duplication is real.

Handling nested or combined form data

Sometimes one form submits a whole cluster of related data. For a checkout, you can flatten the address fields into prefixed attributes and group them when persisting:

# A sketch, not a complete implementation
class CheckoutForm
include ActiveModel::Model
include ActiveModel::Attributes

attribute :email, :string
attribute :shipping_street, :string
attribute :shipping_city, :string
attribute :shipping_postal_code, :string

validates :email, :shipping_street, :shipping_city, :shipping_postal_code, presence: true

def address_attributes
{ street: shipping_street, city: shipping_city, postal_code: shipping_postal_code }
end
end

Flat attributes keep the form and parameters simple. For repeating groups, such as several line items or phone numbers, a Form Object can hold a collection of smaller form objects, but that adds real complexity. In those cases, check whether accepts_nested_attributes_for already solves the problem, which we will compare next.


When to Use Form Objects

Form Objects are a good fit when the form has its own identity. These are the situations where they pay off most.

A form combines input for multiple models. Registration with a profile is the classic case. The Form Object gives that combined input one home, one set of validations, and one save, rather than scattering the logic across the controller and two models.

A form needs validations that apply only to that workflow. If a rule only matters on the sign-up screen, putting it on User means every other code path has to work around it. In a Form Object, the rule lives and applies exactly where it is relevant.

A registration or onboarding process has its own logic. Onboarding steps often collect partial data, apply different requirements at each stage, and create records gradually. A Form Object per step keeps those differing rules from tangling inside one model.

A search form accepts filters that are not database attributes. Keywords, price ranges, and “in stock only” toggles are inputs, not columns. A Form Object can cast and validate them, and then expose the matching results:

class ProductSearchForm
include ActiveModel::Model
include ActiveModel::Attributes

attribute :query, :string
attribute :min_price, :decimal
attribute :max_price, :decimal
attribute :in_stock, :boolean, default: false

validates :min_price, :max_price,
numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validate :price_range_is_logical

def results
return Product.none unless valid?

scope = Product.all
scope = scope.where("name LIKE ?", "%#{Product.sanitize_sql_like(query)}%") if query.present?
scope = scope.where(price: min_price..) if min_price
scope = scope.where(price: ..max_price) if max_price
scope = scope.where(stock_count: 1..) if in_stock
scope
end

private

def price_range_is_logical
return unless min_price && max_price

errors.add(:max_price, "must be greater than the minimum price") if max_price < min_price
end
end

This assumes a Product model with name, price, and stock_count columns. A search form object validates user input and builds a query, but it never saves anything, which shows that “form object” and “persistence” are separate ideas.

A complex form has a clear input and validation boundary. If you can describe the form as “these inputs, these rules, this outcome,” a Form Object gives that description a concrete shape in code.

A workflow benefits from separating form handling from persistence models. Keeping what a user typed apart from what the database stores means each side can change without dragging the other along.


When Not to Use Form Objects

Form Objects solve a specific problem. Without that problem, they are just extra files and indirection.

A simple CRUD form for a single model. If a form creates a Post with a title and body, form_with model: @post is already the best tool. Rails models handle the validations, error display, and persistence, and adding a PostForm only duplicates what exists.

A model with clear and manageable responsibilities. If your model is small and each validation is something that is always true about the data, there is nothing to extract.

A form with only a few straightforward validations. A form that needs two or three standard validators does not justify a new class. Moving a few lines out of a model is not a reason on its own.

An abstraction that duplicates Active Record behavior. If your Form Object copies every attribute and validation from a model and then calls Model.create!(attributes), it adds a layer without adding value. You will maintain the same rules in two places.

A Form Object that grows into a service layer. If your save method sends emails, calls external APIs, updates analytics, and creates audit logs, it has stopped being a form and become a catch-all workflow. That makes it hard to read, test, and change.

Ordinary Active Record models remain a great choice for straightforward CRUD. The pattern is a tool for specific situations, not a default architecture.


Form Objects vs Alternatives

Form Objects overlap with a few other patterns, but each solves a different problem, and they often work together.

Active Record models

A model is the right home for data integrity rules and persistence of a single table. Start here. Reach for a Form Object only when the form’s needs diverge from the model’s.

Service Objects

A Service Object wraps a business operation, such as charging a customer or transferring ownership of a project. Its focus is what the application does, while a Form Object’s focus is what the user submitted. They fit together naturally: a Form Object validates and coordinates the input, and a Service Object performs a distinct operation once the input is accepted. You can read more about the second pattern in this guide to Ruby on Rails service objects.

Many apps need only one of the two, and many need neither. Do not add both just because both exist.

Nested attributes

Rails already has a built-in way to save a parent and its children from one form: accepts_nested_attributes_for with fields_for. If the records are tightly related (an order and its line items), it can be the simplest solution. The costs are that validation rules for the nested records live on the parent model’s association, and workflow-specific rules tend to leak into the model. This overview of Rails nested attributes covers when that built-in approach fits best. A Form Object is a better choice when the form’s rules are specific to one screen or when the records involved are not naturally parent and child.

Plain Ruby objects

A bare Ruby class works for internal logic that never touches a form. Once an object needs to work with form_with, errors, and validations, ActiveModel::Model is what turns a plain object into a practical Form Object.

Query objects

For search forms, a Form Object can validate and type-cast filters, while a dedicated query object builds and returns the relation. The form handles the input, the query object handles the database, and each can be tested separately. For small searches, as in the example above, keeping the query inside the form object is perfectly fine.


Testing Form Objects

Form Objects are easy to test because they are plain Ruby classes. You do not need a controller or a browser to check their behavior. The examples below use RSpec and match the RegistrationForm from earlier. Put them in spec/forms/registration_form_spec.rb.

require "rails_helper"

RSpec.describe RegistrationForm do
def valid_attributes(overrides = {})
{
email_address: "[email protected]",
password: "correct-horse-battery",
password_confirmation: "correct-horse-battery",
display_name: "Ada Lovelace",
time_zone: "UTC",
terms_accepted: true
}.merge(overrides)
end

describe "validations" do
it "is valid with complete input" do
expect(described_class.new(valid_attributes)).to be_valid
end

it "requires the terms to be accepted" do
form = described_class.new(valid_attributes(terms_accepted: false))

expect(form).not_to be_valid
expect(form.errors[:terms_accepted]).to be_present
end

it "rejects a mismatched password confirmation" do
form = described_class.new(valid_attributes(password_confirmation: "something-else-entirely"))

expect(form).not_to be_valid
expect(form.errors[:password_confirmation]).to be_present
end

it "rejects a password that contains the email name" do
form = described_class.new(
valid_attributes(
email_address: "[email protected]",
password: "lovelace-is-my-password",
password_confirmation: "lovelace-is-my-password"
)
)

expect(form).not_to be_valid
expect(form.errors[:password]).to be_present
end
end

describe "#save" do
it "creates a user and a profile" do
form = described_class.new(valid_attributes)

expect { form.save }
.to change(User, :count).by(1)
.and change(Profile, :count).by(1)

expect(form.user.profile.display_name).to eq("Ada Lovelace")
end

it "returns false and creates nothing when the input is invalid" do
form = described_class.new(valid_attributes(display_name: ""))

expect(form.save).to be(false)
expect(User.count).to eq(0)
expect(form.errors[:display_name]).to be_present
end

it "exposes the model error when the email is already taken" do
User.create!(email_address: "[email protected]", password: "another-long-password")
form = described_class.new(valid_attributes)

expect(form.save).to be(false)
expect(form.errors[:email_address]).to include("has already been taken")
end

it "rolls back the user when the profile cannot be created" do
invalid_profile = Profile.new.tap(&:valid?)
allow(Profile).to receive(:create!)
.and_raise(ActiveRecord::RecordInvalid.new(invalid_profile))

form = described_class.new(valid_attributes)

expect { form.save }.not_to change(User, :count)
expect(form.save).to be(false)
end
end
end

A few notes on why the tests are shaped this way.

They test the public behavior. Each example checks what a caller can observe: whether the form is valid, what errors contains, whether save returns true or false, and whether records exist afterward. None of them care how the form builds those records internally, so you can refactor the implementation without rewriting the tests.

Valid and invalid input are both covered. Validation tests need no database at all, which makes them fast. Persistence tests then confirm the successful and failed paths.

The transaction test forces a real failure. The last example makes the second write fail on purpose and then asserts that the first one did not survive. This is the one place where stubbing is justified, because there is no easy way to make a valid form fail halfway. Keep stubs like this to the specific behavior you are checking. Rails wraps each test in its own transaction, and application-level transactions inside it use savepoints, so the rollback assertion behaves as it would in production.

Beyond these specs, add a request spec that submits the form and checks the redirect on success and the 422 response on failure. That confirms the controller, parameters, and view are wired to the Form Object correctly.


Common Mistakes and Best Practices

Most Form Object problems come from a handful of patterns. Here is what to watch for.

Creating Form Objects for every form. Evaluate the need first. If a single model and a normal form_with handle it, you are done.

Duplicating model validations unnecessarily. Validate what the form owns. Let models enforce data integrity and uniqueness, and surface their errors through the form. A little overlap for better user feedback is fine, but copying every rule creates two places to keep in sync.

Forgetting to expose underlying model errors. Errors on a User or Profile do not appear on the Form Object by themselves. Rescue ActiveRecord::RecordInvalid and copy the errors, as shown earlier.

Mixing unrelated business operations into save. Keep save about persisting this form’s data. Notifications, billing, and syncing belong in separate objects or background jobs.

Performing external side effects inside transactions. An email sent before a rollback still reaches the inbox. Run side effects after save returns true.

Ignoring failed persistence. If you call save! inside save without rescuing, an exception can escape to the user as a 500 error. Handle the failure and return false with useful errors.

Building overly generic Form Objects. A GenericUserForm that handles sign-up, editing, admin creation, and imports is a model with extra steps. One form, one object.

Confusing Form Objects with Service Objects. A Form Object answers “what did the user submit and is it acceptable?” A Service Object answers “what should the application do?” Keep the two roles separate.

Not testing validation and persistence. Form Objects are cheap to test. Cover valid input, invalid input, error messages, successful saves, failed saves, and rollback behavior.

A short list of best practices

  • Give each Form Object one clearly named form or workflow, such as RegistrationForm or AccountSettingsForm.
  • Keep controllers thin: build the form, call save, and render or redirect.
  • Use bang methods inside transactions so failures actually roll back.
  • Return true or false from save and keep the errors on the form.
  • Keep models responsible for data integrity and forms responsible for input.
  • Prefer a plain Active Record model until the form genuinely outgrows it.

Conclusion

Rails Form Objects give a form its own home for attributes, validations, and the logic that coordinates saving. Built on ActiveModel::Model and, when you need typed attributes, ActiveModel::Attributes, they work smoothly with form_with, error messages, and strong parameters. They shine when a form has its own validation or coordination responsibilities, especially when multiple models are involved and a transaction keeps the saved records consistent.

They are not a default, though. For a simple CRUD form on a single model, a standard Active Record model is often all you need. Start simple, notice when a form’s rules and workflow stop matching a single model, and introduce a Form Object at that point.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Coder. Apprendre. Progresser.

Une newsletter conviviale où je partage des conseils, des leçons et des réussites de mon parcours de développeur.