What is a form object?

Free preview 7 min to read

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
Lesson 1 of 7 · free

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.

Type to start searching...