← All posts

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.

rails patterns architecture form-objects

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:

  1. The form spans multiple models. A signup that creates a User, an Account, and an Onboarding row in one click is the canonical case. You can’t fit that into one model’s #save without making one model know about the other two.
  2. 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.
  3. 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 contract form_with callers expect. It mirrors ActiveRecord::Base#save, so a controller can write if @form.save and not care whether @form is 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 the BulkImport row and its BulkImportRow children commit together or not at all.
  • insert_all is used instead of N create! 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 :user plus delegation; the form adds request-scoped fields and validations, then calls user.save inside its own #save. The form is the public face; the model is the persistence target.
  • Form creating many records. The signup case: #save opens a transaction, creates the User, then the Account, then the Onboarding. If any inner save returns false, the form merges the errors with errors.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 *Filter to 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 attribute or attr_accessor declarations 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 #call method. Move it to app/services and 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 #save that rescues StandardError, 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’s CreatorSettingsForm rescues StandardError; I’d narrow that to the specific LogoUploader exceptions 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.