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.
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.
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.
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:
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 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.
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.
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.
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
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:
ActiveModel::Modelbrings the parts of a model that are not the database:validatesand theerrorsobject, initialisation from a hash, and the naming conventionsform_withneeds to buildname="onboarding[name]"and point the form at the right URL.ActiveModel::Attributesbrings typed attributes, soattribute :seats, :integercasts"3"to3the way a column would, and a blank string to nil rather than to zero.The line that keeps parameters honest
attribute_namesis the class-level methodActiveModel::Attributesdefines, 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 :phoneto 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:
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_atcan 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 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
The
if attributes.blank?is the whole mechanism for prefilling, and it hinges on a distinction the controller already makes:showbuilds the form with nothing,updatebuilds 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
persistis the extension point. The default writes the columnsuser_attributesnames straight onto the user, which is right for a step editing the user's own fields. A step that fills an associated record overridespersistinstead 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 thanupdateis deliberate too.valid?has already run on the form, so a failure insidepersistis 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/onboardingin 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::Attributesto hold an array of objects andform_withto 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.