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.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<form data-controller="query-sync" data-action="change->query-sync#apply">
  <select name="status" class="rounded border p-2">
    <option value="">Any</option>
    <option value="open">Open</option>
    <option value="closed">Closed</option>
  </select>

Filter UI that syncs query params via Stimulus (no front-end router)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

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