ruby erb 88 lines · 3 tabs

Memoized Query Object for a Cached Dashboard Stat in Rails

Shared by codesnips Jul 2026
3 tabs
class RevenueStat
  DEFAULT_RANGE = 30.days

  def initialize(account, range: DEFAULT_RANGE)
    @account = account
    @range = range
  end

  def mrr_cents
    @mrr_cents ||= Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
      compute_mrr_cents
    end
  end

  def growth_ratio
    previous = previous_period_mrr_cents
    return nil if previous.zero?

    ((mrr_cents - previous).to_f / previous).round(4)
  end

  def cache_key
    ["revenue_stat", @account.id, window.begin.to_i, window.end.to_i].join("/")
  end

  private

  def window
    @window ||= @range.ago..Time.current
  end

  def compute_mrr_cents
    @account.subscriptions
            .active
            .where(started_at: window)
            .sum(:monthly_amount_cents)
  end

  def previous_period_mrr_cents
    @previous_period_mrr_cents ||= @account.subscriptions
                                           .active
                                           .where(started_at: (window.begin - @range)..window.begin)
                                           .sum(:monthly_amount_cents)
  end
end
3 files · ruby, erb Explain with highlit

This snippet shows a common Rails pattern for keeping dashboards fast and controllers thin: a dedicated query object that owns one expensive aggregate, memoizes it per instance, and layers a short-lived cache on top so the same numbers are not recomputed on every request.

In RevenueStat, the class is initialized with an account and an optional time range, so the same object can answer questions about any window. The core work lives in mrr_cents, which uses Rails.cache.fetch keyed on the account and range to avoid hitting Postgres repeatedly, and delegates the actual SQL to the private compute_mrr_cents. That method uses a single grouped sum over active subscriptions so the aggregation happens in the database rather than by loading rows into Ruby. The ||= on @mrr_cents provides in-process memoization, which matters when several parts of a request touch the same stat within one object's lifetime.

The cache_key method builds a stable, human-readable key from the account id and the range boundaries, and growth_ratio shows how a second derived figure can be composed from the memoized base without re-querying. The guard against a zero previous period avoids a divide-by-zero and returns nil so the view can decide how to render a missing trend.

In DashboardsController, the action stays declarative: it instantiates one RevenueStat and one SignupStat, exposing them as @revenue and @signups. Because the query objects memoize internally, the controller and view can call the same reader multiple times cheaply. Using helper_method-free plain instance variables keeps the flow obvious.

The view tab dashboard/index.html.erb renders the computed values, formatting cents with number_to_currency and treating a nil growth_ratio as a neutral state. This separation matters because it puts all query knowledge in one testable object, keeps caching concerns out of the controller, and makes it trivial to unit test compute_mrr_cents in isolation. The main trade-off is cache staleness: expires_in trades perfect freshness for fewer queries, which is usually the right call for a dashboard where second-level accuracy is unnecessary. Reaching for this pattern makes sense whenever a stat is expensive, reused, and read far more often than it changes.


Related snips

Share this code

Here's the card — post it anywhere.

Memoized Query Object for a Cached Dashboard Stat in Rails — share card
Link copied