ruby 86 lines · 3 tabs

Memoizing a Pricing Breakdown as an Immutable Value Object in Rails

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

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

Share this code

Here's the card — post it anywhere.

Memoizing a Pricing Breakdown as an Immutable Value Object in Rails — share card
Link copied