class Order < ApplicationRecord
has_many :line_items, inverse_of: :order, dependent: :destroy
accepts_nested_attributes_for :line_items,
allow_destroy: true,
reject_if: ->(attrs) { attrs["product_id"].blank? && attrs["quantity"].blank? }
validates :customer_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :total_cents, numericality: { greater_than_or_equal_to: 0 }
validates_associated :line_items
validate :must_have_line_items
before_validation :recalculate_total
private
def recalculate_total
self.total_cents = line_items.reject(&:marked_for_destruction?).sum do |item|
item.quantity.to_i * item.unit_price_cents.to_i
end
end
def must_have_line_items
remaining = line_items.reject(&:marked_for_destruction?)
errors.add(:base, "must contain at least one line item") if remaining.empty?
end
end
class LineItem < ApplicationRecord
belongs_to :order, inverse_of: :line_items
validates :product_id, presence: true
validates :quantity,
numericality: { only_integer: true, greater_than: 0 }
validates :unit_price_cents,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validate :product_must_be_orderable
private
def product_must_be_orderable
return if product_id.blank?
return if Product.orderable.exists?(id: product_id)
errors.add(:product_id, "is not available for ordering")
end
end
class OrdersController < ApplicationController
def new
@order = Order.new
@order.line_items.build
end
def create
@order = Order.new(order_params)
if @order.save
redirect_to @order, notice: "Order placed."
else
flash.now[:alert] = "Please fix the errors below."
render :new, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(
:customer_email,
line_items_attributes: [:id, :product_id, :quantity, :unit_price_cents, :_destroy]
)
end
end
This snippet shows how a Rails Order aggregate validates itself together with its child LineItem records using accepts_nested_attributes_for, so that a single form submission either persists as a consistent whole or fails atomically with useful error messages.
In the Order model, has_many :line_items is paired with accepts_nested_attributes_for :line_items so nested params like line_items_attributes map directly onto associated records. reject_if drops rows where both product_id and quantity are blank, which prevents empty form rows from creating junk records, and allow_destroy: true lets the form mark existing items for removal with a _destroy flag. Because nested attributes wrap child saves in the parent's transaction, an invalid line item rolls back the whole order rather than leaving a half-saved record.
A subtle but important detail is validates_associated :line_items: without it, Rails still validates autosaved children, but declaring it makes the intent explicit and surfaces child errors on the parent. The custom validate :must_have_line_items guards against the empty-cart case, which association validations alone cannot express. The before_validation hook computes total_cents from the children so the total is never trusted from the client — a common security pitfall when totals arrive in params.
The LineItem model carries its own field-level rules: presence of product_id, a positive integer quantity, and a non-negative unit_price_cents. Keeping these on the child means each row is validated independently and errors are reported per item, which is exactly what the nested form needs to highlight the offending row.
In the OrdersController, strong parameters explicitly permit the nested array via line_items_attributes, including id and _destroy so updates and deletions work. The action relies on save returning false to render the form with @order.errors already populated across both levels.
This pattern is worth reaching for whenever a parent and its children are edited in one form and must stay consistent. The trade-offs are real: nested attributes couple the form shape to the model, reject_if logic can silently discard data if written carelessly, and deeply nested graphs become hard to reason about — at which point a dedicated form object is usually cleaner.
Related snips
require 'application_system_test_case'
class TasksTest < ApplicationSystemTestCase
test 'deleting a task removes it from the list' do
task = tasks(:one)
System test: asserting Turbo Stream responses
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<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)
Share this code
Here's the card — post it anywhere.