May 4, 2026
When to use a form object in Rails
An opinionated take on form objects, with code from Forem and Mastodon. The bar I apply: a form has to do something the model can't.
Ten years ago I was the developer who put a form object on every controller action that accepted input. It was a phase. Now I treat form objects the way I treat service objects: a worthwhile pattern with a high bar, and most of the ones I see in code review shouldn’t exist.
This is the pattern catalog entry — what a form object is, when it earns the file, two examples from Forem and Mastodon, and the anti-patterns to watch for.
What a form object is
A form object is a PORO that includes ActiveModel::Model, declares the attributes a form submits, validates them as a unit, and exposes a #save that fans out into one or more model writes. It’s what the controller hands to form_with and what knows how to persist what the user typed.
It exists for one reason: a form’s shape doesn’t always match a model’s shape. When the two diverge — extra fields, multiple targets, request-scoped validations — the form object is the seam between HTTP input and the domain.
The textbook shape
Here’s Forem’s CreatorSettingsForm, which an admin uses when configuring a community for the first time:
# forem/forem · app/forms/creator_settings_form.rb · @9eb974c (AGPL-3.0, 15-line excerpt)
class CreatorSettingsForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :checked_code_of_conduct, :boolean, default: false
attribute :checked_terms_and_conditions, :boolean, default: false
attribute :community_name, :string
attribute :invite_only_mode, :boolean
attribute :logo
attribute :primary_brand_color_hex, :string
attribute :public, :boolean
validates :community_name, :primary_brand_color_hex, presence: true
# ... boolean inclusion validations, attr_accessor :success, save method
end
Three things to notice. The typed attribute declarations come from ActiveModel::Attributes, which gives you string/boolean/integer coercion for free — params[:invite_only_mode] == "1" arrives as a real true. The acknowledgment booleans aren’t stored on any database row; they exist only for the duration of the request, to gate the submit button. And the validations live on the form because no model has a community_name attribute that would need them.
The #save (omitted for length) writes those values to four Settings::* namespaces — Community, UserExperience, Authentication, General. One submission, four destinations. That is the case for a form object, in one snippet.
When you actually need one
I apply three tests, and a form object should pass at least one:
- The form spans multiple models. A signup that creates a
User, anAccount, and anOnboardingrow in one click is the canonical case. You can’t fit that into one model’s#savewithout making one model know about the other two. - The form has fields the model doesn’t. Acknowledgment checkboxes, password confirmation, captcha tokens, “I’m not a bot” — all are page-bound, not domain-bound. The model has no business carrying them.
- The form’s validation rules differ from the model’s. Sign-up has “password must match confirmation”; login doesn’t. Creating a comment on a locked thread requires moderator status; creating one elsewhere doesn’t. Push the rule to the form when it’s request-scoped.
If none of these are true — if the form is a textarea, a label, and a button that updates post.body — you don’t need a form object. You need form_with(model: @post) and the model’s existing validations. A PostUpdateForm in that case is ceremony.
The complex case: validating before persisting
Here’s Mastodon’s Form::Import, which handles CSV uploads of follows, blocks, mutes, and bookmarks:
# mastodon/mastodon · app/models/form/import.rb · @bdad4f7 (AGPL-3.0, 14-line excerpt)
class Form::Import
include ActiveModel::Model
MODES = %i(merge overwrite).freeze
FILE_SIZE_LIMIT = 20.megabytes
ROWS_PROCESSING_LIMIT = 20_000
# ... EXPECTED_HEADERS_BY_TYPE, ATTRIBUTE_BY_HEADER constants ...
attr_accessor :current_account, :data, :type, :overwrite, :bulk_import
validates :type, presence: true
validates :data, presence: true
validate :validate_data
end
And the save method:
# mastodon/mastodon · app/models/form/import.rb · @bdad4f7 (AGPL-3.0, 10-line excerpt)
def save
return false unless valid?
ApplicationRecord.transaction do
now = Time.now.utc
@bulk_import = current_account.bulk_imports.create(type: type, overwrite: overwrite || false, state: :unconfirmed, original_filename: data.original_filename, likely_mismatched: likely_mismatched?)
nb_items = BulkImportRow.insert_all(parsed_rows.map { |row| { bulk_import_id: bulk_import.id, data: row, created_at: now, updated_at: now } }).length
@bulk_import.update(total_items: nb_items)
end
end
The directory is app/models/form/, not app/forms/ — that’s a directory choice, not a pattern choice. What makes it a form object is the shape: ActiveModel validation, a #save, a valid? gate before the transaction opens.
Three things in the #save are worth pulling out:
- The early
return false unless valid?is the contractform_withcallers expect. It mirrorsActiveRecord::Base#save, so a controller can writeif @form.saveand not care whether@formis a model or a form object. - The transaction wraps the database writes only. Validation runs first. By the time you’re inside
ApplicationRecord.transaction, the input is known well-formed; the transaction guarantees theBulkImportrow and itsBulkImportRowchildren commit together or not at all. insert_allis used instead of Ncreate!calls, because the rows can number in the thousands (capped at 20,000). The form is the right place for that decision: the model layer doesn’t know it’s being called from a bulk import.
Variations I’ve found load-bearing
Three shapes show up in real codebases:
- Form wrapping one model.
attr_accessor :userplus delegation; the form adds request-scoped fields and validations, then callsuser.saveinside its own#save. The form is the public face; the model is the persistence target. - Form creating many records. The signup case:
#saveopens a transaction, creates theUser, then theAccount, then theOnboarding. If any innersavereturnsfalse, the form merges the errors witherrors.merge!and rolls back. - Form that doesn’t persist anything. The search form: validates the query, coerces the page parameter, hands itself to a query object. No
#save. I call these*Filterto be honest about what they do.
Naming and location
The Rails community has never agreed on where form objects live: app/forms/ (Forem), app/models/form/ (Mastodon), app/models/forms/, sometimes mixed into app/services/. The directory matters less than the suffix. I want the class name to end in Form so a reader scanning the file tree knows what they’re looking at. SignUp is ambiguous; SignUpForm isn’t.
I haven’t reached for reform or any of the heavier form-object gems on a project I started this decade. ActiveModel::Model and ActiveModel::Attributes ship with Rails and have covered every case I’ve hit.
Anti-patterns to watch for
Four I push back on every time:
- The form that’s a service. If the class has no
attributeorattr_accessordeclarations matching form fields, and its job is to coordinate a workflow, it’s a service. The tell: a “form object” with one constructor arg that’s a model and a#callmethod. Move it toapp/servicesand rename it. - The form that’s delegation in disguise.
attr_accessor :name, :email; delegate :save, to: :user. That’s a model with extra steps and a worse error story. Use the model directly and put the validations on it. - The form that swallows errors. A
#savethat rescuesStandardError, sets@success = false, and returns. This is how a malformed CSV silently disappears in production. Validate up front, raise on truly unexpected errors, only rescue what you understand. (Forem’sCreatorSettingsFormrescuesStandardError; I’d narrow that to the specificLogoUploaderexceptions if I owned the code.) - One form per controller action. Once a team starts producing
EditPostForm,UpdatePostForm,PublishPostForm, the form layer is a parallel controller layer — each one a small change to the model’s interface dressed up as architecture. Three actions on one resource usually want one form object, or none.
Testing
The form’s spec is the simplest spec you’ll write all week: instantiate with a hash of attributes, assert valid?, call save, assert the database state. No mocks, no stubs, no shared examples. Test the validation messages, not only the predicates — expect(form.errors[:community_name]).to include("can't be blank") catches the regression where someone changes the message and breaks an i18n key the front-end depended on.
The takeaway
Reach for a form object when the form’s fields, validations, or destinations don’t fit a single model. Don’t reach for one when they do. accepts_nested_attributes_for handles more cases than developers tend to remember, and form_with(model:) against a real Active Record object is still the shortest path from controller to database row. Form objects exist because that path sometimes doesn’t fit the request. When it does fit, take it.
Code excerpts: creator_settings_form.rb — Forem AGPL-3.0, pinned at SHA 9eb974c4d30df25b6bd7fb7854431758f61826f4. import.rb — Mastodon AGPL-3.0, pinned at SHA bdad4f78f309af6ac439dac4f7705818550e7c08. Both used as ≤15-line excerpts under fair use.