ruby 54 lines · 3 tabs

ActiveRecord::Relation as a Boundary (No Arrays)

Shared by codesnips Jan 2026
3 tabs
class OverdueInvoicesQuery
  def initialize(relation: Invoice.all, as_of: Time.current)
    @relation = relation
    @as_of = as_of
  end

  def call
    past_due(unpaid(@relation))
  end

  private

  def unpaid(scope)
    scope.where(status: %w[open partial])
  end

  def past_due(scope)
    scope.where(arel_due_before)
  end

  def arel_due_before
    Invoice.arel_table[:due_on].lt(@as_of)
  end
end
3 files · ruby Explain with highlit

The pattern demonstrated here is treating ActiveRecord::Relation as the return type at every layer of the query stack, rather than eagerly materializing rows into arrays. A Relation is lazy and composable: it builds SQL but does not run until something enumerates it. The moment a method calls .to_a, .map, or .select {} (the block form), that laziness collapses, the query fires, and callers lose the ability to add where, order, limit, or includes downstream. Preserving the relation keeps composition, pagination, and eager-loading decisions in the hands of the caller.

In OverdueInvoicesQuery, the query object exposes a single call that returns a relation, chaining private helpers that each accept and return a relation. Because unpaid and past_due both end in .where(...), the class composes cleanly without ever touching the database. The initialize takes a relation: defaulting to Invoice.all — this is the key to composability, since a caller can inject an already-scoped relation (for example customer.invoices) and the query narrows it further. Nothing runs until enumeration.

In Invoice model, the scopes for_currency and recent return relations too, and overdue delegates straight into the query object. Notice overdue does not call .to_a; it hands back the relation so Invoice.overdue.for_currency("USD").limit(50) remains valid SQL composition rather than in-memory array filtering.

In InvoicesController, the boundary pays off: index starts from current_account.invoices, layers optional scopes based on params, applies includes(:customer) to avoid N+1, and only materializes via page(...).per(...) from Kaminari at the very end. Because each stage returned a relation, the pagination and eager-load happen in one round trip.

The trade-off is discipline — helpers must resist .select {}, .detect, and .map on relations, since those force loading. The payoff is that filtering, counting, and pagination all push down into the database, and callers compose behavior without re-querying. This approach is worth reaching for whenever a method feeds into further filtering or pagination; returning an array is only appropriate at the true end of the chain, in the view or serializer.


Related snips

Share this code

Here's the card — post it anywhere.

ActiveRecord::Relation as a Boundary (No Arrays) — share card
Link copied