Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 044 / 100

Form objects: concepts, trade-offs and production lessons

Forms that span multiple models.

Expona Admin7 min read

Form objects: concepts, trade-offs and production lessons

Multi-model forms are a reality in almost every production Rails application, yet they often expose weaknesses in how we structure data flows. A user registration might simultaneously create an account, a profile, and a subscription record—each with interdependent validations. The form objects pattern addresses this by wrapping multiple database operations behind a single, coherent interface that keeps controllers thin and validation logic testable.

Why form objects matter

Rails scaffolding teaches us to use one model per form. Reality is messier. In a ride-hailing platform, creating a new driver involves validating the driver record, creating an associated vehicle, setting up payment details, and triggering a background check workflow. Trying to orchestrate this across multiple controllers or cramming it into a single model leads to bloated models that violate single responsibility and creates brittle controller logic.

Form objects solve this by acting as a presentation layer between your controller and your domain models. They collect input, validate across model boundaries, and coordinate persistence. The pattern is particularly valuable in three scenarios:

  • Multi-step workflows where you need to persist intermediate state safely
  • Forms that create or update multiple associated records atomically
  • Complex validations that depend on relationships between different entities

ActiveModel::Model for plain objects

The foundation of form objects is ActiveModel::Model, a Rails mixin that gives Plain Old Ruby Objects (POROs) the interface of an ActiveRecord model without a backing table. This lets you use validations, error handling, and attribute assignment on any Ruby class.

ruby
class DriverRegistrationForm
  include ActiveModel::Model

  attr_accessor :name, :email, :password, :vehicle_type, :license_number

  def initialize(driver_attributes = {}, vehicle_attributes = {})
    @driver_attributes = driver_attributes
    @vehicle_attributes = vehicle_attributes
    
    super(driver_attributes.merge(vehicle_attributes))
  end

  def save
    if valid?
      ActiveRecord::Base.transaction do
        @driver = Driver.create!(@driver_attributes)
        @vehicle_attributes[:driver_id] = @driver.id
        Vehicle.create!(@vehicle_attributes)
      end
      true
    else
      false
    end
  end

  def persisted?
    false
  end
end

In your controller, this becomes straightforward:

ruby
class DriversController < ApplicationController
  def create
    @form = DriverRegistrationForm.new(
      driver_params,
      vehicle_params
    )

    if @form.save
      redirect_to dashboard_path, notice: 'Driver registered'
    else
      render :new
    end
  end

  private

  def driver_params
    params.require(:driver).permit(:name, :email, :password)
  end

  def vehicle_params
    params.require(:vehicle).permit(:vehicle_type, :license_number)
  end
end

The controller is now five lines of logic instead of twenty. Anyone reading this code immediately understands what happens: validate, persist multiple records, or fail.

Validations without tables

Form objects let you express validation rules that cross model boundaries. You can validate that the combination of inputs is valid even when individual models would be valid in isolation.

ruby
class DriverRegistrationForm
  include ActiveModel::Model

  attr_accessor :name, :email, :password, :password_confirmation,
                :vehicle_type, :license_number, :license_expiry

  validates :name, :email, :password, presence: true
  validates :password, confirmation: true, length: { minimum: 8 }
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :license_number, :vehicle_type, presence: true
  validate :license_not_expired
  validate :email_not_already_used
  validate :valid_vehicle_type_for_license

  private

  def license_not_expired
    return if license_expiry.blank?
    
    if Date.parse(license_expiry) < Date.today
      errors.add(:license_expiry, 'must be in the future')
    end
  end

  def email_not_already_used
    if Driver.exists?(email: email)
      errors.add(:email, 'is already registered')
    end
  end

  def valid_vehicle_type_for_license
    # Cross-model business logic
    if vehicle_type == 'truck' && !has_commercial_license?
      errors.add(:vehicle_type, 'requires commercial license')
    end
  end

  def has_commercial_license?
    license_number.start_with?('CDL')
  end
end

This validation layer doesn't touch the database until we know all inputs are valid. We've decoupled validation from persistence, which means faster tests and clearer error messages in forms.

Save multiple records in a transaction

Atomicity is critical. If creating a driver succeeds but creating their vehicle fails halfway through, we have orphaned records. The save method should handle all-or-nothing semantics using database transactions.

ruby
class SubscriptionUpgradeForm
  include ActiveModel::Model

  attr_accessor :plan_id, :billing_email, :card_token

  validates :plan_id, :billing_email, :card_token, presence: true

  def initialize(user, attributes = {})
    @user = user
    super(attributes)
  end

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      # Create subscription record
      @subscription = Subscription.create!(
        user_id: @user.id,
        plan_id: plan_id,
        started_at: Time.current,
        status: 'pending'
      )

      # Process payment
      payment_result = PaymentGateway.charge(
        amount: Plan.find(plan_id).price_cents,
        token: card_token,
        email: billing_email
      )

      unless payment_result.success?
        raise "Payment failed: #{payment_result.error}"
      end

      # Update subscription status
      @subscription.update!(
        status: 'active',
        payment_id: payment_result.id
      )

      # Send confirmation email
      SubscriptionMailer.confirmation_email(@user, @subscription).deliver_later
    end

    true
  rescue => e
    errors.add(:base, "Failed to process subscription: #{e.message}")
    false
  end
end

If any step fails, the entire transaction rolls back. Your database stays consistent. In production, we've seen this prevent duplicate charges, orphaned records, and cascading failures.

Keep controllers thin

Controllers should orchestrate, not decide. A thin controller using form objects reads like a story: receive input, validate it using the form object, persist or report errors.

ruby
class OnboardingController < ApplicationController
  def step_two_create
    @form = TeamInvitationForm.new(current_user, form_params)

    if @form.save
      redirect_to onboarding_step_three_path
    else
      @form.errors.messages.each do |field, messages|
        logger.info "Validation error on #{field}: #{messages}"
      end
      render :step_two
    end
  end

  private

  def form_params
    params.require(:team_invitation).permit(:email, :role_id)
  end
end

Compare this to the alternative: manually validating, catching exceptions, creating records conditionally, sending emails conditionally. The form object isolates complexity where it belongs—in the business logic layer—leaving the controller as a simple HTTP handler.

Common mistakes

Mistake 1: Form object knows too much about implementation. A form object should validate and coordinate, not fetch database records you don't need or perform unrelated operations. If you're doing three database queries in your form object's save method, you've mixed concerns.

Mistake 2: Using form objects for simple, single-model forms. This adds boilerplate without benefit. Reserve form objects for genuinely complex multi-model scenarios. A simple "edit profile" form works fine with User directly.

Mistake 3: Forgetting to test the form object independently. One advantage of form objects is their testability. If you only test them through controller specs, you're missing the point. Test validation, business logic, and error cases directly on the form object.

ruby
describe DriverRegistrationForm do
  describe '#save' do
    it 'creates driver and vehicle in a transaction' do
      form = DriverRegistrationForm.new(
        { name: 'Alice', email: 'alice@example.com', password: 'secure123' },
        { vehicle_type: 'sedan', license_number: 'ABC123' }
      )

      expect { form.save }.to change(Driver, :count).by(1)
                          .and change(Vehicle, :count).by(1)
    end

    it 'rolls back if vehicle creation fails' do
      form = DriverRegistrationForm.new(
        { name: 'Bob', email: 'bob@example.com', password: 'secure123' },
        { vehicle_type: nil, license_number: 'XYZ789' }
      )

      expect { form.save }.not_to change(Driver, :count)
      expect(form.errors[:vehicle_type]).to be_present
    end
  end
end

Mistake 4: Not handling external service failures gracefully. If your form object calls an API (payment processor, email service, background check system), wrap it in a rescue block and surface user-friendly errors through the form's error object.

Key takeaways

  • Form objects wrap multi-model forms using ActiveModel::Model, giving you validations and error handling without a database table
  • Cross-model validation logic belongs in the form object, not scattered across controllers or models
  • Always use transactions in form object save methods to guarantee atomicity across multiple records
  • Controllers become simpler and more testable when business logic moves into dedicated form objects
  • Reserve form objects for genuinely complex multi-model scenarios; don't use them for simple single-model forms
  • Test form objects directly as well as through integration tests to catch validation and persistence bugs early
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles