LaunchKit

Form objects in Rails, with ActiveModel

September 19, 2026

A form that does not map to one table is where Rails developers reach for the model anyway. The signup asks for two fields, the onboarding asks for six more across four screens, and the User model ends up carrying validations that only apply on a screen it has never heard of.

The shape of a step

class Onboarding::ProfileForm < Onboarding::BaseForm
  attribute :name, :string
  validates :name, presence: true

  private

  def apply_defaults = self.name = user.name
  def user_attributes = { name: name }
end

Four meaningful lines: what the step collects, what makes it valid, what it shows when rendered, and what it writes. A reader answering "what does the profile step do" has one file to open and no inheritance to chase.

The base class is what makes it that short:

class Onboarding::BaseForm
  include ActiveModel::Model
  include ActiveModel::Attributes
end

ActiveModel::Model brings the parts of a model that are not the database: validates and the errors object, initialisation from a hash, and the naming conventions form_with needs to build name="onboarding[name]" and point the form at the right URL. ActiveModel::Attributes brings typed attributes, so attribute :seats, :integer casts "3" to 3 the way a column would, and a blank string to nil rather than to zero.

The line that keeps parameters honest

params.fetch(:onboarding, ActionController::Parameters.new)
      .permit(*@step.form_class.attribute_names)

attribute_names is the class-level method ActiveModel::Attributes defines, and it returns exactly what the form declared. For the first step of this flow it answers ["first_name", "last_name"].

Writing that list by hand in the controller is the version that rots. Add attribute :phone to a step, forget the controller, and the field renders, the user types into it, the browser posts it, and strong parameters drops it without a word. The form validates against nil, the error says the field is required, and the user is looking at a field they just filled in. Nothing logs, nothing raises.

Deriving it means the two cannot disagree. It also means the permit list stays narrow: each step permits only its own fields, so a crafted parameter naming another step's column is dropped rather than assigned.

Why not put it on the model

The alternative writes itself, which is the problem:

class User < ApplicationRecord
  validates :company_name, presence: true, if: -> { onboarding_step == "workspace" }
end

That line works on the day it is written. What it costs arrives later, from somewhere else: the admin screen that updates a user's email now runs a validation about a company name, an integration test that builds a user fails on a field the test has never heard of, and a background job that touches last_seen_at can fail to save for reasons belonging to a screen nobody involved is looking at.

Every conditional validation on a model is a rule that every other write has to satisfy or dodge. Three of them and the User model is unusable without knowing the onboarding flow. The form object inverts it: the rule lives where it applies, and the User model stays a record about a user.

The same validation drawn twice. On the left, under on the User model, a yellow box reads validates colon company underscore name with the condition if onboarding underscore step equals quote workspace quote; an arrow down leads to a list headed runs on every save of a user, with three white rows: the onboarding step it was written for, the admin screen editing an email, and a factory, a job, anything touching User. On the right, under in the step's form object, a green box reads the same validation inside Onboarding colon colon WorkspaceForm; an arrow down leads to a heading reading runs when that step is submitted and a single white row reading the workspace step, followed by the note: and nowhere else, the User model stays a record about a user, and every other write keeps saving.

The cost of the inversion is real and worth naming. Fields are now declared in two places, the form object and the partial that renders them, and a fifth step means a new class and a new partial rather than a line in an existing file. That is the trade, and it buys a User model that any part of the app can save.

Rendering versus submitting

def initialize(user, attributes = {})
  @user = user
  super(attributes)
  apply_defaults if attributes.blank?
end

The if attributes.blank? is the whole mechanism for prefilling, and it hinges on a distinction the controller already makes: show builds the form with nothing, update builds it with params.

So a rendered step arrives prefilled from the user record, and a submitted one does not get its values quietly restored. Drop the condition and a user who clears a field gets it refilled from the record they were trying to change, which reads as the form ignoring them.

Saving, and where the step writes

def save
  return false unless valid?

  ActiveRecord::Base.transaction { persist }
  true
end

def persist = user.update!(user_attributes)

persist is the extension point. The default writes the columns user_attributes names straight onto the user, which is right for a step editing the user's own fields. A step that fills an associated record overrides persist instead and builds that record itself.

The transaction is there for exactly that second case. A step writing to the user and to an association is two writes, and without the wrapper a failure on the second leaves the first committed: the user half-updated, the step not marked done, and the flow about to render the same questions over data that already moved.

update! rather than update is deliberate too. valid? has already run on the form, so a failure inside persist is not a user error, it is a mismatch between the form's rules and the database's. Raising surfaces it; the non-bang version would return false into a transaction that then commits nothing and reports success.

Where these get used

The onboarding flow builds one of these per step and hands it the params, which is all the controller does with them. Seven of them sit in app/forms/onboarding in this codebase, one per step the flow can show.

The pattern is not specific to onboarding, and that is the argument for learning it here rather than treating it as flow machinery. A search filter with six optional fields, a bulk action taking a list of ids and a reason, a contact form that sends mail and writes nothing, an import that validates a file before touching a row: all of them post fields that do not line up with one table, and all of them otherwise end up as a model with attributes it does not store or a controller doing validation by hand. The form object is the same eight lines every time.

What this page does not cover

Nested attributes. A form object collecting a list of rows, three invitations at once for example, needs ActiveModel::Attributes to hold an array of objects and form_with to render them with indexed names, and none of the steps here do that.

Nor does it cover reusing a form object outside a controller. These are built with a user and saved in a request; calling one from a background job or a console script works, and the valid? then guards input nobody typed, which is a different threat model than the one the validations were written for.

More on Onboarding in Rails

← All Onboarding in Rails articles