What is a form object?
The form that doesn't fit any model
Imagine a course registration page. The user fills in one form, but behind the scenes that single submission must create a User, an Enrollment and an initial XP award. Which model should validate all those fields? User knows nothing about enrollments, and Enrollment knows nothing about passwords.
That's the wall every growing Rails application hits: a form whose data doesn't map cleanly to one Active Record model. The fields span several models, the validations depend on the context, and the logic has no natural home in the model layer.
A form object is a plain Ruby object that stands in for a model behind one form. It collects the parameters, validates them for that form's context, and leaves persistence to a service object. It exists to keep form concerns apart from model concerns.
Why models aren't enough
Active Record models carry two responsibilities. They define the shape of your data (columns, types, associations), and they enforce business invariants such as "an email must be unique" or "a course must have a title". These are always-true rules: they apply however and wherever the data is created.
Forms add context-specific rules. A sign-up form requires a password confirmation, and an admin form that creates a user doesn't. A registration form requires accepting the terms of service, and an API endpoint doesn't. A multi-step wizard validates different fields at each step.
Put context-specific validations into the model, and you get code like this:
class User < ApplicationRecord
validates :password_confirmation, presence: true, if: :signing_up?
validates :terms_accepted, acceptance: true, if: :from_registration_form?
validates :admin_notes, presence: true, if: :created_by_admin?
attr_accessor :signing_up, :from_registration_form, :created_by_admin
def signing_up?
signing_up == true
end
end
This model now knows about sign-up flows, registration forms and admin workflows. Each new form adds another flag, another if: clause and another method the model shouldn't need.
The model has broken the single responsibility principle: it's responsible for data integrity and for form-specific validation, two concerns that change for different reasons.
The form object solution
A form object moves the context-specific logic into its own class. It's a plain Ruby object that includes ActiveModel::Model and ActiveModel::Attributes, which give it just enough Rails integration to work with form_with, validations and error messages, without touching the database.
# A hypothetical form object; this app has no app/forms directory
module Auth
class RegistrationForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :name, :string
attribute :email, :string
attribute :password, :string
attribute :password_confirmation, :string
attribute :terms_accepted, :boolean
validates :name, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true, length: { minimum: 8 }
validates :password_confirmation, presence: true
validates :terms_accepted, acceptance: true
validate :passwords_must_match
private
def passwords_must_match
return if password == password_confirmation
errors.add(:password_confirmation, "doesn't match password")
end
end
end
This class owns every validation specific to the registration form. The User model keeps only its always-true invariants, such as email uniqueness and the password length, and the form object handles the rest.
What this app does instead
This app has no form objects. Its sign-up is a single-model form: Auth::RegistrationsController passes the parameters straight to Auth::RegisterUserService, which builds an Identity::User with a password_confirmation. The model's validations check the email, username and password, and has_secure_password checks that the confirmation matches. With one model and no extra fields, that's enough, which is the "no" row of the decision table below.
A form object would earn its place the day sign-up grew a terms checkbox, or started creating an enrollment in the same submission.
Where form objects sit in DDD
Each layer has one job:
HTTP Request
|
v
+------------------+
| Controller | Receives params, returns HTTP response
+------------------+
|
v
+------------------+
| Form Object | Validates input for this specific context
+------------------+
|
v
+------------------+
| Service Object | Executes business logic, persists data
+------------------+
|
v
+------------------+
| Model | Data shape, always-true invariants
+------------------+
The controller receives the parameters and hands them to the form object. The form object validates them, and if they're valid, the controller passes the validated data to a service object, which runs the business logic and returns a Result.
Each layer knows nothing about the layers above it. The service doesn't know it was called from a form, and the model doesn't know a service used it. That's separation of concerns in practice.
The controller stays thin
With a form object, the controller becomes a simple orchestrator:
# A hypothetical version of the controller that uses the form object above
module Auth
class RegistrationsController < ApplicationController
def new
@form = RegistrationForm.new
end
def create
@form = RegistrationForm.new(registration_params)
if @form.valid?
result = RegisterUserService.call(
name: @form.name,
email: @form.email,
password: @form.password
)
if result.success
redirect_to root_path, notice: "Welcome!"
else
@form.errors.add(:base, result.error)
render :new, status: :unprocessable_content
end
else
render :new, status: :unprocessable_content
end
end
private
def registration_params
params.require(:auth_registration_form).permit(
:name, :email, :password,
:password_confirmation, :terms_accepted
)
end
end
end
The controller does three things: build the form, check that it's valid, call the service. It holds no business logic, no complex validations and no database queries. That's what "controllers handle HTTP only" looks like.
When you need a form object
Not every form needs one. Here's a decision guide:
| Situation | Form object? | Why |
|---|---|---|
| Simple CRUD on one model | No | Active Record validations are enough |
| Fields that aren't in any model | Yes | No model to put the validations on |
| One form creates several models | Yes | No single model owns all the fields |
| Context-specific validations | Yes | Keeps conditional logic out of the models |
| Multi-step wizard | Yes | Each step validates different fields |
| Virtual fields, such as a terms checkbox | Yes | Virtual fields don't belong on models |
| An API endpoint with the same data | No, or a different form | A different context has different validations |
The rule of thumb: if you're adding attr_accessor or if: :some_context? to a model for one form, extract a form object instead.
Common mistakes
The first mistake is putting persistence in the form object. Its job is validation, not saving records, and mixing the two defeats the separation. The form object validates; if the data is valid, the controller calls a service to persist it.
The second is creating a form object for every form. Simple CRUD forms that map to one model don't need the extra layer, and over-engineering slows the team down. Reach for a form object when models start collecting context-specific validations.
The third is duplicating model validations in the form object. If the model and the form both check the email format, you have two sources of truth, and a change to the rule has to be made twice. The form validates form concerns, such as the password confirmation and the terms checkbox, and the model validates data invariants.
Uniqueness is a special case: a form object can't declare it at all. validates :email, uniqueness: true in a class that only includes ActiveModel::Model raises ArgumentError: Unknown validator: 'UniquenessValidator', because that validator belongs to Active Record and needs the database. Uniqueness stays on the model, backed by a unique index.
Summary
| Concept | Key point |
|---|---|
| Form object | A plain Ruby object that stands in for a model behind one form and holds its context-specific validations |
| The problem | Models fill up with conditional validations when different forms need different rules |
ActiveModel::Model |
Gives a plain object validations, naming and errors, everything form_with needs |
| Position in DDD | Controller, then form object, then service, then model |
| Separation of concerns | Form: input rules for this context. Model: always-true invariants. Service: business logic |
| When to use one | Multi-model forms, context-specific validations, virtual fields, multi-step wizards |
| When not to | Simple CRUD on one model, like this app's sign-up |
Keep going with lesson 2
ActiveModel::Model & attributes
Explains how ActiveModel::Model and typed attributes let a plain object work with form_with and validations.
A free account also gives you the quizzes, your progress and notes, and the discussion.