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 need | Store |
|---|---|
| A table with pagination, filters, sorting | Denormalized PG table |
| Heavy aggregations over millions of rows | PG materialized view |
| Hot lookups by ID or a short key | Redis |
| Full-text search, multi-field filters | ElasticSearch / 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.
ElasticSearch — for full-text search
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_summaryand 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
SELECTwith noJOINreturns 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
ViewRepositorywith a read-onlyAsyncSessionand plain SQL, withoutcommit. - 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.
What to read next
- 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.