| name | memoization-patterns |
| description | In-process memoization patterns for Rails — instance variable ||=, nil/false-safe memoization, multi-argument caching, request-scoped CurrentAttributes, and the memo_wise gem. Use when avoiding repeated expensive computations within a single object or request lifecycle. |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
Memoization Patterns for Rails
Overview
Memoization caches the result of a method call in memory for the lifetime of the object (or request). It is not the same as Rails.cache (which survives across requests and processes):
| Technique | Scope | Use When |
|---|
| `@ivar | | =` |
defined? guard | Object instance | Return value can be nil or false |
| Hash memoization | Object instance | Method accepts arguments |
CurrentAttributes | Single request | Share computed value across controllers/views |
Rails.cache.fetch | Cross-request / cross-process | Expensive DB/API call, reuse across requests |
Rule of thumb: use memoization when:
- The method is called more than once in the same object lifecycle.
- The computation is non-trivial (DB query, calculation, API call).
- The result is deterministic for the lifetime of the object.
1. Basic ||= Memoization
class PostPresenter
def initialize(post)
@post = post
end
def comment_count
@post.comments.count
end
def comment_count
@comment_count ||= @post.comments.count
end
def word_count
@word_count ||= @post.body.split.size
end
end
2. Nil/False-Safe Memoization
||= fails silently when the value is nil or false — it re-executes every time:
def published?
@published ||= @post.published_at.present?
end
def published?
return @published if defined?(@published)
@published = @post.published_at.present?
end
def admin_user
return @admin_user if instance_variable_defined?(:@admin_user)
@admin_user = User.find_by(role: :admin)
end
When you need nil/false-safe memoization:
- Boolean methods (can return
false)
find_by queries (can return nil)
- Any method where the "empty" value is meaningful
3. Multi-Argument Memoization
When a method takes arguments, use a Hash keyed by arguments:
class PricingService
def price_for(product, quantity)
@price ||= calculate_price(product, quantity)
end
def price_for(product, quantity)
@prices ||= {}
@prices[[product.id, quantity]] ||= calculate_price(product, quantity)
end
private
def calculate_price(product, quantity)
product.base_price * quantity * discount_factor(quantity)
end
end
With keyword arguments:
def fetch_events(status:, limit: 10)
@events_cache ||= {}
@events_cache[[status, limit]] ||= Event.where(status: status).limit(limit).to_a
end
4. Memoization in Service Objects
Memoize intermediate results to avoid redundant queries within a single call:
module Invoices
class GenerateService < ApplicationService
def initialize(order:)
@order = order
end
def call
return Failure("No line items") if line_items.empty?
return Failure("Invalid total") unless total_cents.positive?
invoice = Invoice.create!(
order: @order,
user: user,
line_items: line_items,
total_cents: total_cents
)
Success(invoice)
rescue ActiveRecord::RecordInvalid => e
Failure(e.record.errors.full_messages.join(", "))
end
private
def line_items
@line_items ||= @order.line_items.includes(:product).to_a
end
def user
@user ||= @order.user
end
def total_cents
@total_cents ||= line_items.sum { |li| li.quantity * li.product.price_cents }
end
end
end
5. Memoization in Query Objects
class DashboardStatsQuery
def initialize(account:)
@account = account
end
def summary
{
events_count: events_count,
revenue_cents: revenue_cents,
pending_orders: pending_orders_count
}
end
private
def published_events
@published_events ||= @account.events.published.to_a
end
def events_count
published_events.size
end
def revenue_cents
@revenue_cents ||= @account.orders.completed.sum(:total_cents)
end
def pending_orders_count
@pending_orders_count ||= @account.orders.pending.count
end
end
6. Request-Scoped Memoization with CurrentAttributes
CurrentAttributes lives for the duration of a single request and is reset automatically. Use it to share expensive lookups across controllers, views, and mailers without passing objects down the call chain.
class Current < ActiveSupport::CurrentAttributes
attribute :user, :account, :plan
def plan
super || (self.plan = account&.active_plan)
end
end
class ApplicationController < ActionController::Base
before_action :set_current_attributes
private
def set_current_attributes
Current.user = current_user
Current.account = current_user&.account
end
end
class PostsController < ApplicationController
def create
return redirect_to upgrade_path unless Current.plan&.allows?(:post_creation)
@post = Current.account.posts.build(post_params)
end
end
What belongs in Current vs instance variables:
Use Current | Use @ivar |
|---|
| Data shared across layers (controller → view → mailer) | Data local to one object |
| Current user, account, tenant | Computed intermediaries in a service |
| Feature flags / plan limits | Aggregated query results |
7. Memoization in Presenters
Presenters format data for views — they are constructed once per render and may call the same formatting logic many times:
class EventPresenter < ApplicationPresenter
def initialize(event, current_user:)
@event = event
@current_user = current_user
end
def display_date
@display_date ||= I18n.l(@event.starts_at, format: :long)
end
def vendor_names
@vendor_names ||= vendors.map(&:name).join(", ")
end
def ticket_price_range
@ticket_price_range ||= build_price_range
end
def can_edit?
return @can_edit if defined?(@can_edit)
@can_edit = @current_user.admin? || @event.organizer == @current_user
end
private
def vendors
@vendors ||= @event.vendors.active.order(:name).to_a
end
def build_price_range
prices = @event.ticket_tiers.map(&:price_cents)
return "Free" if prices.all?(&:zero?)
"#{format_cents(prices.min)} – #{format_cents(prices.max)}"
end
def format_cents(cents)
Money.from_cents(cents).format
end
end
8. memo_wise Gem (Advanced)
For classes with many memoized methods, memo_wise provides a cleaner DSL and handles nil/false/multi-argument automatically:
gem "memo_wise"
class ReportGenerator
prepend MemoWise
def initialize(account:, period:)
@account = account
@period = period
end
memo_wise def total_revenue
@account.orders.completed.where(created_at: @period).sum(:total_cents)
end
memo_wise def top_products(limit: 5)
@account.products.best_selling.limit(limit).to_a
end
memo_wise def conversion_rate
return 0.0 if visits.zero?
(conversions.to_f / visits * 100).round(2)
end
private
memo_wise def visits
Analytics.count_visits(account: @account, period: @period)
end
memo_wise def conversions
Analytics.count_conversions(account: @account, period: @period)
end
end
memo_wise advantages over manual ||=:
- Handles
nil and false automatically (no defined? needed)
- Handles method arguments automatically (no
@cache ||= {} needed)
reset_memo_wise / preset_memo_wise for testing
- Works with inheritance
9. Thread Safety
Instance variable memoization is thread-safe in Rails because each request gets its own controller instance. However, class-level memoization is not thread-safe:
class FeatureFlags
def self.enabled?(flag)
@flags ||= load_flags
@flags[flag]
end
end
class FeatureFlags
MUTEX = Mutex.new
def self.enabled?(flag)
MUTEX.synchronize { @flags ||= load_flags }[flag]
end
end
class FeatureFlags
def self.enabled?(flag)
Rails.cache.fetch("feature_flags", expires_in: 5.minutes) { load_flags }[flag]
end
end
Rule: Only memoize in instance methods or use Rails.cache for class/module-level caching.
10. When NOT to Memoize
def current_status
@current_status ||= @order.reload.status
end
class BackgroundWorker
def process_orders
orders = @orders ||= Order.pending.to_a
end
end
def single_use_result
@result ||= complex_calculation
end
def formatted_name
@formatted_name ||= "#{first_name} #{last_name}"
end
11. Testing Memoized Methods
Test the result, not the memoization:
RSpec.describe EventPresenter do
let(:event) { create(:event) }
let(:presenter) { described_class.new(event, current_user: create(:user)) }
describe "#vendor_names" do
it "returns comma-separated vendor names" do
create(:vendor, event: event, name: "Alpha Catering")
create(:vendor, event: event, name: "Beta Sound")
expect(presenter.vendor_names).to eq("Alpha Catering, Beta Sound")
end
end
end
Test that memoization actually caches (when it matters):
RSpec.describe Invoices::GenerateService do
let(:order) { create(:order, :with_line_items) }
let(:service) { described_class.new(order: order) }
it "queries line_items only once even when called multiple times in call" do
2.times { service.send(:line_items) }
query_count = 0
counter = ->(*, **) { query_count += 1 }
ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
service.send(:line_items)
end
expect(query_count).to eq(0)
end
end
Reset memoization between examples:
RSpec.describe EventPresenter do
after { presenter.reset_memo_wise }
end
Test nil-safe memoization:
RSpec.describe PostPresenter do
describe "#published?" do
it "returns false without re-querying" do
post = create(:post, published_at: nil)
presenter = described_class.new(post)
expect(presenter.published?).to be false
expect(presenter).not_to receive(:published_at)
expect(presenter.published?).to be false
end
end
end
Quick Reference
@result ||= expensive_call
return @result if defined?(@result)
@result = call_that_may_return_nil_or_false
@cache ||= {}
@cache[arg] ||= expensive_call(arg)
@cache ||= {}
@cache[[arg1, arg2]] ||= expensive_call(arg1, arg2)
Current.attribute_name ||= compute_once
Rails.cache.fetch("key", expires_in: 5.minutes) { load_data }
Checklist