ruby 103 lines · 3 tabs

Rails Nested Address Form Object With Aggregated ActiveModel Errors

Shared by codesnips Jul 2026
3 tabs
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
3 files · ruby Explain with highlit

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.

Rails Nested Address Form Object With Aggregated ActiveModel Errors — share card
Link copied