SRP Series · 2 of 7
Form Objects
Where the pattern comes from, why ActiveModel exists at all, and how Gumroad uses Admin::SalesReport to keep validation logic out of its sales table.
Where this rule comes from
For most of Rails' early life, validations and persistence lived in the same class. The User model knew what a user looked like in the database, and the same User model knew which inputs were valid before saving. That works as long as every form in the app maps one-to-one to a database table. The signup form makes a User. The post form makes a Post. ActiveRecord handles both.
The cracks appear the day someone needs a form whose inputs are not a row. An admin form that takes a date range and triggers a report. A multi-step signup wizard where step 1 collects a name, step 2 collects a password, and the actual User does not exist until step 3. A "send invitations to multiple emails at once" form where each email is validated independently and each invitation is its own record. None of these have a single table they correspond to, and yet they all need the same validation machinery that ActiveRecord gave us: validators, error messages, form-builder integration.
Rails 3 acknowledged the problem in 2010 by extracting that validation machinery out of ActiveRecord and into a new gem called ActiveModel. Any class that includes ActiveModel::Model gets the full Rails validation API without needing a database table behind it. Two years later, Bryan Helmkamp wrote the post that gave the pattern its name: 7 Patterns to Refactor Fat ActiveRecord Models. The form object was the first pattern on the list.
The rule the pattern enforces is straightforward. Persistence and validation are different responsibilities. The User model owns "what a user is in the database" (columns, associations, callbacks that fire on save). A form object owns "what counts as a valid set of inputs for this specific form." Both can use the same ActiveModel validation API. They do not need to be the same class.
The anti-pattern
Picture a Rails app where the admin section keeps growing. Someone built a "Generate sales report" page two years ago, and instead of creating a new class for it, they stapled the form behavior onto the Sale model. The result looks something like this:
class Sale < ApplicationRecord
# ... 200 lines of normal Sale logic ...
# Admin reports use these fields. They don't exist on the table.
attr_accessor :report_country_code, :report_start_date,
:report_end_date, :report_sales_type
validates :report_country_code, presence: true,
if: :generating_report?
validates :report_start_date, presence: { message: "use YYYY-MM-DD" },
if: :generating_report?
validates :report_end_date, presence: { message: "use YYYY-MM-DD" },
if: :generating_report?
def generating_report?
report_country_code.present? || report_start_date.present?
end
end The Sale model now has two reasons to change. The sales team owns its actual concept of a sale (the columns, the relationships, the way a sale becomes a refund). The admin tooling team owns this report form, which has nothing to do with what a sale is and everything to do with what an admin can input. Every change to the report form opens a model file used by dozens of unrelated callers.
There is a second, subtler problem. The validations are guarded by an if: :generating_report? condition that depends on virtual attributes. When the validation fires for a normal sale save, the report fields are nil, and the guard returns false, so the validation is skipped. The day someone changes the guard logic (or accidentally sets report_country_code in a different context), the model starts running report validations on regular sales. Conditional validations look harmless until they fail in surprising contexts, and this kind of failure shows up in production, not in tests.
How Gumroad solves it
Gumroad has the same situation in its admin section: a "Generate sales report" form whose inputs (country, date range, report type) need validation, and a button that fires off a background job to generate the actual report. Their solution is a plain Ruby class that includes ActiveModel::Model but is not backed by a table. The class lives at app/models/admin/sales_report.rb:
# gumroad/app/models/admin/sales_report.rb · @8f6f1c60
# License: MIT
class Admin::SalesReport
include ActiveModel::Model
YYYY_MM_DD_FORMAT = /\A\d{4}-\d{2}-\d{2}\z/
INVALID_DATE_FORMAT_MESSAGE = "Invalid date format. Please use YYYY-MM-DD format"
ACCESSORS = %i[country_code start_date end_date sales_type].freeze
attr_accessor(*ACCESSORS)
ACCESSORS.each do |accessor|
define_method("#{accessor}?") do
public_send(accessor).present?
end
end
validates :country_code, presence: { message: "Please select a country" }
validates :start_date, presence: { message: INVALID_DATE_FORMAT_MESSAGE }
validates :end_date, presence: { message: INVALID_DATE_FORMAT_MESSAGE }
validates :start_date, comparison: {
less_than: :end_date, message: "must be before end date",
if: %i[start_date? end_date?]
}
validates :start_date, comparison: {
less_than_or_equal_to: -> { Date.current },
message: "cannot be in the future", if: :start_date?
}
validates_inclusion_of :sales_type,
in: GenerateSalesReportJob::SALES_TYPES
end
Read the class carefully. It does not inherit from ApplicationRecord. There is no sales_reports table. There is no save method on this class, because saving makes no sense. Calling valid? on it runs the validations and populates the errors object. That is the entire job of the class.
Notice the small ergonomic trick at the top. The four field names live in a single ACCESSORS constant, then a meta-programming loop defines both the attr_accessor and a field? predicate for each one. The predicate methods are used inside the conditional validations (if: %i[start_date? end_date?]), so when the form is partially filled in, only the validations that have enough data to run actually fire. The pattern keeps the validations declarative without making them lie.
The controller treats this form-object exactly like an ActiveRecord model, because ActiveModel::Model gives it the same surface:
class Admin::SalesReportsController < Admin::ApplicationController
def create
@report = Admin::SalesReport.new(report_params)
if @report.valid?
GenerateSalesReportJob.perform_async(@report.attributes)
redirect_to admin_sales_reports_path, notice: "Generating report…"
else
render :new, status: :unprocessable_entity
end
end
end The controller does not know or care that this is not a database-backed model. It calls new, it calls valid?, it reads errors. The form helpers in the view use form_with(model: @report) and it works without any extra configuration, because ActiveModel provides everything Rails form helpers expect.
Why this design holds up
Four benefits come out of the extraction, each one solving a specific failure mode of the fat-Sale version.
The Sale model stays about sales. The columns, the associations, the actual business logic of what a sale is. It changes when the concept of a sale changes, not when the admin team decides the date format should be different.
The validations are unconditional. There is no more if: :generating_report? guard. The form object exists for one purpose; when it exists, the validations apply. When it does not exist, they do not. Less conditional logic means fewer edge cases where a guard returns the wrong answer in the wrong context.
The form integrates with Rails form helpers out of the box. Because ActiveModel gives the class the same public surface as ActiveRecord (errors, valid?, attributes, model_name), every Rails view helper that expects a model also works on the form object. form_with(model: @report), f.label, f.text_field, the error-summary partial — all of it.
Multi-step wizards become trivial. Each step of a signup wizard is its own form object with its own validations: SignupStep1Form, SignupStep2Form, SignupStep3Form. Each one validates the inputs for its own step. The actual User record gets created at the end, by a service object that takes the validated data from all three steps. Validation and persistence stay separate even when the flow that ties them together is complex.
When you should reach for one
Pull the trigger when any of these conditions is true:
- The form's fields do not map one-to-one to a single table. Multi-model forms, search forms, report forms, wizard steps.
- The form has validations that should not apply during normal model use. If you are reaching for
if: ...guards on validations, the conditions probably belong in their own class. - The form submits an action rather than a record. "Generate this report," "export this data," "send these invites" — none of those produce a single row to save.
- The form coordinates multiple models on submit. If saving the form means creating a User, a Profile, and an Organization, the form object holds the validated inputs and a service object does the orchestrated saves.
One discipline to maintain: the form object should not also save records. If the form is valid, call a service to do the actual work. Keep "validate input" separate from "do the action." Two responsibilities, two classes. The form object plus the service object together replace what used to be a fat model method.
The principle at play
The Single Responsibility Principle is doing the work here, the same way it does in service objects. The User model has a stakeholder: the team that owns user data and its lifecycle. A signup wizard has a different stakeholder: the team that owns the onboarding flow. Forcing them to share a class means every flow change is also a model change, and every model change risks breaking a flow that uses it.
What is special about form objects is that they show how the same idea (validations, error messages, attribute access) can be useful for two genuinely different responsibilities. ActiveRecord's mistake, if it was a mistake, was bundling validation with persistence. ActiveModel fixed that by separating the validation machinery from the storage machinery. Once they are separable, you can use the validation half by itself anywhere it makes sense.
The rule of thumb: if a class has fields, validations, and a save method that writes to a table, it is a model. If a class has fields and validations but no save (or its "save" is to fire a job), it is a form object. They look similar from the outside, and that is the point. The difference is which responsibility the class is taking on.
Practice exercise
- Open the largest model in your app. Grep for
attr_accessordeclarations. Each one is a virtual attribute, not a column. Note them down. - Grep the same file for
validates ... if:. Conditional validations on a model are almost always form-shaped baggage. Note those too. - For each match, ask the question: "Is this validation always true about this model, or only when one specific form is being submitted?" If the answer is the second one, the validation belongs in a form object.
- Sketch the form-object name. Common patterns:
{Verb}Formfor an action-shaped form (GenerateReportForm,InviteTeammatesForm) or{Context}::{Subject}for a scoped one (Admin::SalesReport,Onboarding::Step1). - Bonus: open
app/views/and look for forms with field names that do not exist on any model column.f.text_field :confirm_password,f.select :report_type,f.date_field :start_dateon a model that does not store dates. Each of those is a form object hiding inside a model that does not own those fields.