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
class Invoice < ApplicationRecord
belongs_to :account
belongs_to :customer
scope :for_currency, ->(code) { where(currency: code) }
scope :recent, -> { order(created_at: :desc) }
def self.overdue(relation: all, as_of: Time.current)
OverdueInvoicesQuery.new(relation: relation, as_of: as_of).call
end
def self.total_outstanding
where(status: %w[open partial]).sum(:balance_cents)
end
end
class InvoicesController < ApplicationController
def index
invoices = current_account.invoices
invoices = Invoice.overdue(relation: invoices) if params[:overdue].present?
invoices = invoices.for_currency(params[:currency]) if params[:currency].present?
@invoices = invoices
.includes(:customer)
.recent
.page(params[:page])
.per(25)
@outstanding = invoices.total_outstanding
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
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.