class AddressForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :line1, :string
attribute :line2, :string
attribute :city, :string
attribute :region, :string
attribute :postal_code, :string
attribute :country, :string, default: "US"
validates :line1, :city, :region, :country, presence: true
validates :postal_code, presence: true
validates :postal_code,
format: { with: /\A\d{5}(-\d{4})?\z/, message: "is not a valid ZIP code" },
if: -> { country == "US" && postal_code.present? }
def to_h
attributes.symbolize_keys
end
end
class CheckoutForm
include ActiveModel::Model
attr_accessor :email, :same_as_shipping
attr_reader :shipping_address, :billing_address
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validate :validate_addresses
def initialize(params = {})
params = params.to_h.deep_symbolize_keys
@email = params[:email]
@same_as_shipping = ActiveModel::Type::Boolean.new.cast(params[:same_as_shipping])
build_addresses(params)
end
def persist!
return false unless valid?
Order.create!(
email: email,
shipping_address: shipping_address.to_h,
billing_address: effective_billing.to_h
)
end
def effective_billing
same_as_shipping ? shipping_address : billing_address
end
private
def build_addresses(params)
@shipping_address = AddressForm.new(params[:shipping_address] || {})
@billing_address = AddressForm.new(params[:billing_address] || {})
end
def validate_addresses
merge_child_errors(:shipping_address, shipping_address)
return if same_as_shipping
merge_child_errors(:billing_address, billing_address)
end
def merge_child_errors(scope, address)
return if address.valid?
address.errors.each do |error|
errors.add(:"#{scope}.#{error.attribute}", error.message)
end
end
end
class CheckoutsController < ApplicationController
def new
@checkout_form = CheckoutForm.new
end
def create
@checkout_form = CheckoutForm.new(checkout_params)
if @checkout_form.persist!
redirect_to order_confirmation_path, notice: "Order placed."
else
flash.now[:alert] = "Please fix the highlighted fields."
render :new, status: :unprocessable_entity
end
end
private
def checkout_params
params.require(:checkout).permit(
:email,
:same_as_shipping,
shipping_address: address_keys,
billing_address: address_keys
)
end
def address_keys
%i[line1 line2 city region postal_code country]
end
end
This snippet shows the form-object pattern for handling a nested address form in Rails without leaning on accepts_nested_attributes_for. The idea is to keep validation logic out of the ActiveRecord models and inside a plain object that knows how to parse the params, validate a whole graph of data, and surface a single flat error collection the view can render.
In AddressForm, an ActiveModel::Model is used so the object behaves like any other model for form helpers and validations while staying free of persistence concerns. It declares typed attributes with attribute and validates each field independently, including a presence and a format check for postal_code. Treating the address as its own validatable unit means the same rules can be reused anywhere an address appears, not just inside a User.
CheckoutForm is the aggregate root. It composes a shipping_address and an optional billing_address, both built lazily from nested params in build_addresses. The key mechanism is validate_addresses: it runs valid? on each child and, when a child is invalid, copies every child error into the parent using errors.add. The nested keys are namespaced (for example :"shipping_address.postal_code") so the parent's error hash stays flat and unambiguous while still pointing at the offending field. This is the crux of "collect errors" — one call to checkout_form.valid? yields the full, merged set from the top-level fields and both addresses.
The same_as_shipping flag demonstrates conditional composition: when set, billing validation is skipped and the shipping address is reused, avoiding duplicate error noise. persist! is guarded by valid? and returns false on failure so the controller can branch cleanly.
In CheckoutsController, checkout_params uses strong parameters to permit the nested shipping_address and billing_address hashes, then hands them to the form. The controller stays thin: it only decides whether to redirect or re-render :new with status: :unprocessable_entity, and the form object owns all business rules. A pitfall worth noting is that child errors must be copied on the same request the parent is validated, since the merged errors live only on the parent instance; re-instantiating the form drops them. This pattern trades a little boilerplate for testable, model-agnostic validation and precise per-field feedback in complex forms.
Share this code
Here's the card — post it anywhere.