← Back to the section

The classic way to read data is to load an object from the database and run a few JOINs to assemble the needed response. When there are thousands of queries per second and the data is scattered across dozens of tables, this approach starts to slow down. A read-model is a different idea: the data is laid out in advance in exactly the form the consumer wants to receive it. One SELECT — a ready-made response with no JOIN.

What a read-model is

Imagine a shop with orders. To display a customer's list of orders you need data from three tables: the order, the order line items, and the customer profile. With the usual approach, each query does several table joins.

A read-model is a separate table (or another store) in which everything is already assembled. Every time something changes on the write side, the read-model is updated. When the user requests data, we read directly from this ready-made projection.

The main principle: the read-model schema is dictated by the consumer, not by the aggregate. It need not repeat the structure of the write side.

write schema:                         read schema (order_summary):
  order(id, customer_id, status)        order_summary(
  order_item(order_id, qty, price)         order_id,
  customer(id, name, email)               customer_name,   ← from customer
                                          customer_email,  ← from customer
                                          status,
                                          item_count       ← computed from order_item
                                       )

The source of truth is always the write side. A read-model is a derivative that can be rebuilt from scratch.

Where to store a read-model

The store is chosen to fit the nature of the queries, not personal preference.

What you needStore
A table with pagination, filters, sortingDenormalized PG table
Heavy aggregations over millions of rowsPG materialized view
Hot lookups by ID or a short keyRedis
Full-text search, multi-field filtersElasticSearch / OpenSearch

A PG table — the first choice

A denormalized table in the same database is almost always the first step. No new infrastructure, synchronization through the outbox.

CREATE TABLE order_summary (
    order_id        BIGINT PRIMARY KEY,
    customer_id     BIGINT NOT NULL,
    customer_name   TEXT NOT NULL,
    customer_email  TEXT NOT NULL,
    status          TEXT NOT NULL,
    item_count      INTEGER NOT NULL,
    total_amount    NUMERIC(19,4) NOT NULL,
    currency        TEXT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL,
    confirmed_at    TIMESTAMPTZ,
    updated_at      TIMESTAMPTZ NOT NULL,
    version         BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX ix_os_customer    ON order_summary (customer_id, created_at DESC);
CREATE INDEX ix_os_status_date ON order_summary (status, created_at DESC);

The version field helps the event consumer avoid applying a stale update twice.

A PG materialized view — for precomputed aggregations

A summary like "revenue per product for the month" is recomputed rarely but read often. A materialized view computes it in advance and stores it as an ordinary table:

CREATE MATERIALIZED VIEW product_revenue_daily AS
SELECT
    p.product_id,
    p.name,
    DATE(oi.created_at)              AS day,
    SUM(oi.quantity * oi.unit_price) AS revenue,
    COUNT(DISTINCT o.id)             AS order_count
FROM order_item oi
JOIN product p ON p.product_id = oi.product_id
JOIN "order" o ON o.id = oi.order_id
WHERE o.status IN ('CONFIRMED', 'SHIPPED', 'DELIVERED')
GROUP BY p.product_id, p.name, DATE(oi.created_at);

CREATE UNIQUE INDEX ux_prd_pk ON product_revenue_daily (product_id, day);

Refresh via REFRESH MATERIALIZED VIEW CONCURRENTLY on a schedule or on the OrderConfirmed event.

Redis — for hot lookups

If the same data is read on every user request (for example, the subscription plan tier), it fits well into Redis:

# adapters/out/persistence/redis_subscription_view_repository.py
import json
from dataclasses import dataclass
from redis.asyncio import Redis

@dataclass(frozen=True)
class SubscriptionPlan:
    plan: str
    expires_at: str

class RedisSubscriptionViewRepository:
    def __init__(self, redis: Redis) -> None:
        self._redis = redis

    async def find_by_customer(self, customer_id: int) -> SubscriptionPlan | None:
        raw = await self._redis.get(f"customer:{customer_id}:plan")
        if raw is None:
            return None
        data = json.loads(raw)
        return SubscriptionPlan(plan=data["plan"], expires_at=data["expires_at"])

    async def upsert(self, customer_id: int, plan: SubscriptionPlan) -> None:
        await self._redis.set(
            f"customer:{customer_id}:plan",
            json.dumps({"plan": plan.plan, "expires_at": plan.expires_at}),
            ex=3600,
        )

The difference from an ordinary cache: here Redis is the primary store for the response, not a fallback on a cache miss.

Search over descriptions with filters across dozens of attributes and ranking is a task for an inverted index:

# adapters/out/search/es_product_view_repository.py
from dataclasses import dataclass
from elasticsearch import AsyncElasticsearch

@dataclass(frozen=True)
class ProductSearchResult:
    product_id: int
    name: str
    price: int
    in_stock: bool
    rating: float

class ElasticsearchProductViewRepository:
    def __init__(self, es: AsyncElasticsearch) -> None:
        self._es = es

    async def search(
        self,
        q: str,
        min_rating: float | None = None,
        in_stock: bool | None = None,
    ) -> list[ProductSearchResult]:
        must = [{"match": {"name": q}}]
        filters = []
        if min_rating is not None:
            filters.append({"range": {"rating": {"gte": min_rating}}})
        if in_stock is not None:
            filters.append({"term": {"in_stock": in_stock}})

        resp = await self._es.search(
            index="products",
            query={"bool": {"must": must, "filter": filters}},
        )
        return [_to_result(hit["_source"]) for hit in resp["hits"]["hits"]]

How to read from a read-model: ViewRepository

To read from a read-model, a separate ViewRepository is created — it works through plain SQL in a read-only session. No commit, no FOR UPDATE locking, no loading the aggregate through the ORM.

First — the port interface:

# core/order/port/out/order_view_repository.py
from typing import Protocol

class OrderViewRepository(Protocol):
    async def summary(self, order_id: int) -> OrderSummaryView | None: ...
    async def list_by_customer(
        self, customer_id: int, limit: int, offset: int
    ) -> list[OrderSummaryView]: ...
    async def upsert_batch(self, rows: list[OrderSummaryUpsert]) -> None: ...

The implementation through SQLAlchemy with text():

# adapters/out/persistence/sqlalchemy_order_view_repository.py
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

class SqlAlchemyOrderViewRepository:
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def summary(self, order_id: int) -> OrderSummaryView | None:
        result = await self._session.execute(
            text("""
                SELECT order_id, customer_id, customer_name, customer_email,
                       status, item_count, total_amount, currency,
                       created_at, confirmed_at
                  FROM order_summary
                 WHERE order_id = :order_id
            """),
            {"order_id": order_id},
        )
        row = result.mappings().one_or_none()
        return to_order_summary_view(dict(row)) if row else None

    async def list_by_customer(
        self, customer_id: int, limit: int, offset: int
    ) -> list[OrderSummaryView]:
        result = await self._session.execute(
            text("""
                SELECT order_id, customer_id, customer_name, customer_email,
                       status, item_count, total_amount, currency,
                       created_at, confirmed_at
                  FROM order_summary
                 WHERE customer_id = :customer_id
                 ORDER BY created_at DESC
                 LIMIT :limit OFFSET :offset
            """),
            {"customer_id": customer_id, "limit": limit, "offset": offset},
        )
        return [to_order_summary_view(dict(r)) for r in result.mappings()]

A read-only session is created through a separate engine — without autocommit, and commit must not be called:

# infrastructure/db/session.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

read_engine = create_async_engine(READ_DSN, pool_size=10)
ReadSession: async_sessionmaker[AsyncSession] = async_sessionmaker(
    read_engine, expire_on_commit=False
)

The query handler simply calls the repository — no domain logic:

# core/order/usecase/get_order_summary.py
from dataclasses import dataclass

@dataclass(frozen=True)
class GetOrderSummary:
    order_id: int

class GetOrderSummaryHandler:
    def __init__(self, order_view: OrderViewRepository) -> None:
        self._order_view = order_view

    async def handle(self, query: GetOrderSummary) -> OrderSummaryView | None:
        return await self._order_view.summary(query.order_id)

How a read-model is updated: through events

A read-model must not be updated synchronously inside the same transaction that saves the aggregate. That violates the independence of the sides: a change to the read-model schema would start affecting write transactions, and a rollback of the aggregate might not roll back the already-changed projection.

The correct path is through events:

1. the command handler saves Order,
   writes OrderConfirmed → the outbox table (one transaction)
2. the outbox relay publishes the event to Kafka
3. the read-side consumer catches OrderConfirmed
4. UPDATE order_summary
   SET status = 'CONFIRMED', confirmed_at = :confirmed_at, version = version + 1
   WHERE order_id = :order_id AND version < :event_version

The delay under normal conditions is 100 ms–1 s. This is expected behavior: the UI should be aware that data is updated with a small delay. This is called eventual consistency.

How to rebuild a read-model from scratch

A read-model is a derivative. If it is lost (a Redis failure, a dropped table, a migration), it can be rebuilt by walking over the write-side aggregates.

For this a separate rebuilder service is made:

# core/order/service/order_summary_rebuilder.py
import logging
from datetime import datetime, timezone

logger = logging.getLogger(__name__)

class OrderSummaryRebuilder:
    def __init__(
        self,
        orders: OrderRepository,
        customers: CustomerRepository,
        order_view: OrderViewRepository,
    ) -> None:
        self._orders = orders
        self._customers = customers
        self._order_view = order_view

    async def rebuild_all(self) -> None:
        last_id = 0
        batch_size = 500

        while True:
            batch = await self._orders.find_all_after(last_id, batch_size)
            if not batch:
                break

            customer_ids = {order.customer_id.value for order in batch}
            customers = await self._customers.find_by_ids(customer_ids)
            customer_map = {c.id.value: c for c in customers}

            rows = [
                self._to_summary_row(order, customer_map[order.customer_id.value])
                for order in batch
            ]
            await self._order_view.upsert_batch(rows)

            last_id = batch[-1].id.value
            logger.info("rebuild progress: last_id=%d", last_id)

When this is needed:

  • Recovery after a failure — the Redis cluster went down, the ElasticSearch index was deleted.
  • Migration to a new store — you add ElasticSearch, which is still empty, and need to load the historical data into it.
  • A structural change to the read schema — you added a new field to order_summary and want to populate it for the old records.

If there is no rebuilder, the read-model turns into a primary data source — which is unacceptable.

Common mistakes

Business invariants in the read table. If you add a CHECK constraint to order_summary like "the amount cannot be negative", it means the read-model starts dictating business rules. Invariants live only in the aggregate on the write side.

A read-model as the single source of truth. If the write side does not store enough data for a rebuild, the read-model becomes irreplaceable — and any loss of it is catastrophic. The source of truth is always the write side.

A synchronous write to the read-model from the write transaction. Convenient in the short term, but it couples the two sides tightly. As soon as one side changes its schema, the other breaks.

Loading the aggregate through the main repository for query requests. The ORM loads an object graph, makes extra queries, applies lazy loading. For reading, use a separate ViewRepository with a plain SELECT.

In short

  • A read-model is a projection of data in a form convenient for reading: one SELECT with no JOIN returns a ready response.
  • The read-model schema is dictated by the consumer, not by the write side; they may differ greatly.
  • The store is chosen to fit the nature of the queries: a PG table for tabular queries, a materialized view for aggregations, Redis for hot lookups, ElasticSearch for full-text search.
  • Reading is through a ViewRepository with a read-only AsyncSession and plain SQL, without commit.
  • Updates are only through events (outbox → Kafka → consumer), not synchronously inside a write transaction.
  • A read-model is always rebuildable from the write side — for that a rebuilder is needed.
  • The source of truth is the write-side aggregates; the read-model is a derivative.
  • Sync via events — how the outbox and Kafka deliver events to the read-model.
  • Query side — how the query handler reads from the read-model.
  • Command side — what a command handler returns and why not a read-DTO.
  • CQRS tier and evolution — when to move from a simple split to an event-driven read-model.