ruby sql 68 lines · 4 tabs

Database Views for Read Models

Shared by codesnips Jan 2026
4 tabs
class CreateCustomerRevenueSummaries < ActiveRecord::Migration[7.0]
  def change
    create_view :customer_revenue_summaries, materialized: true, version: 1

    add_index :customer_revenue_summaries,
              :customer_id,
              unique: true,
              name: "idx_customer_revenue_summaries_on_customer_id"

    add_index :customer_revenue_summaries, :total_revenue_cents
  end
end
4 files · ruby, sql Explain with highlit

This snippet shows how to back a read-heavy reporting screen with a Postgres materialized view instead of computing aggregates on every request. The pattern is a lightweight form of CQRS: writes go to normalized tables (orders, line_items), while reads come from a denormalized, pre-aggregated view that is refreshed on a schedule. It trades absolute freshness for fast, predictable reads and a simple query surface.

The CreateCustomerRevenueSummaries migration uses the Scenic gem's create_view with materialized: true. Scenic keeps the SQL for each view version in a db/views directory, so the migration only names the view and its version rather than embedding a large SQL string inline. Because it is materialized, the view stores its result set on disk; a unique index on customer_id is added so the view can later be refreshed with CONCURRENTLY, which requires at least one unique index and avoids locking readers during a refresh.

The customer_revenue_summaries_v01.sql tab is the actual view definition. It joins orders to line_items, filters to completed orders, and rolls the data up per customer with count, sum, and max. This is exactly the kind of query that is expensive to run repeatedly and cheap to read once materialized.

In CustomerRevenueSummary model, the view is treated as an ordinary read-only Active Record model. readonly? returns true so no code accidentally tries to write through it, and refresh wraps Scenic.database.refresh_materialized_view with concurrently: true. The scopes (top_spenders, active_since) show that a view behaves like any other relation for querying and pagination.

The RefreshRevenueSummariesJob handles staleness. Materialized views do not update themselves, so a scheduled job calls CustomerRevenueSummary.refresh periodically; the concurrently refresh keeps the dashboard queryable throughout. The main pitfalls to weigh are refresh cost on large datasets, the bounded staleness window between refreshes, and the requirement that the unique index exists before a concurrent refresh will work. This approach fits dashboards, leaderboards, and analytics endpoints where slightly stale data is acceptable and read latency matters.


Related snips

Share this code

Here's the card — post it anywhere.

Database Views for Read Models — share card
Link copied