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.
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
endIn your controller, this becomes straightforward:
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
endThe 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.
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
endThis 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.
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
endIf 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.
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
endCompare 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.
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
endMistake 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
savemethods 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






Comments
Loading comments…