class PriceBreakdown < Struct.new(:subtotal_cents, :discount_cents, :tax_cents, keyword_init: true)
def initialize(*)
super
freeze
end
def total_cents
subtotal_cents - discount_cents + tax_cents
end
def to_h
{
subtotal_cents: subtotal_cents,
discount_cents: discount_cents,
tax_cents: tax_cents,
total_cents: total_cents
}
end
def ==(other)
other.is_a?(PriceBreakdown) && to_h == other.to_h
end
end
class PriceCalculator
TAX_RATE = 0.0875
def initialize(line_items:, coupon: nil)
@line_items = line_items
@coupon = coupon
end
def self.call(**kwargs)
new(**kwargs).call
end
def call
subtotal = @line_items.sum { |li| li.unit_price_cents * li.quantity }
discount = discount_for(subtotal)
taxable = subtotal - discount
tax = (taxable * TAX_RATE).round
PriceBreakdown.new(
subtotal_cents: subtotal,
discount_cents: discount,
tax_cents: tax
)
end
private
def discount_for(subtotal)
return 0 if @coupon.nil?
if @coupon.percentage?
(subtotal * @coupon.value / 100.0).round
else
[@coupon.value_cents, subtotal].min
end
end
end
class Cart < ApplicationRecord
has_many :line_items, dependent: :destroy
belongs_to :coupon, optional: true
after_save :reset_price_breakdown!
def price_breakdown
@price_breakdown ||= PriceCalculator.call(
line_items: line_items.to_a,
coupon: coupon
)
end
def total_cents
price_breakdown.total_cents
end
def apply_coupon(new_coupon)
self.coupon = new_coupon
reset_price_breakdown!
end
def reset_price_breakdown!
@price_breakdown = nil
end
end
This snippet shows how to compute a cart's pricing once and expose it as an immutable value object, avoiding the classic problem of recomputing subtotals, discounts, and taxes on every view or serializer call. The pattern separates the calculation (a service that knows the rules) from the result (a plain value object that just holds numbers), and memoizes the result on the aggregate that owns it.
In PriceBreakdown value object, the result is modeled with Struct.new(..., keyword_init: true) and frozen in initialize via freeze. Freezing matters because a memoized object tends to be shared across the request; if any caller mutated it, every other caller would silently see the change. The value object carries only derived data — subtotal, discount, tax, and a computed total — plus a small to_h for serialization. It holds no reference back to the cart, so it can be cached, compared, or logged safely.
In PriceCalculator service, the domain rules live in one place. call builds a PriceBreakdown from line items, applying a coupon and a tax rate. Keeping this logic out of the model keeps the calculation testable in isolation and lets it evolve (new promotions, regional taxes) without touching persistence code. All money is handled in integer cents to sidestep floating-point rounding drift.
In Cart model, price_breakdown is the memoization seam: it uses @price_breakdown ||= ... so the calculator runs at most once per loaded instance. The subtle part is invalidation — reset_price_breakdown! clears the memo, and it is wired to after_save plus called whenever the coupon changes, because a stale memo is worse than no memo. The ||= idiom will re-run if the computed value is ever nil, which is why the value object is always a real object, never nil.
The trade-off is scope: this memo lives only for the lifetime of one in-memory Cart; it is not a shared cache and resets on reload. That is exactly what is wanted for per-request pricing, where correctness after edits matters more than cross-request reuse. Reach for this pattern when a computed result is read many times per request, is expensive or rule-heavy to derive, and must stay consistent with the object it describes.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
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
Share this code
Here's the card — post it anywhere.