В обычном приложении одна таблица служит и для записи, и для чтения. На чтение приходится добавлять JOIN-ы, группировки, подсчёты — прямо в момент запроса. При росте нагрузки это тормозит: запросы становятся сложными, индексы перекрываются, а оптимизировать одну схему сразу под запись и под чтение очень трудно.
Read-model — это отдельное представление данных, уже сложенных так, как нужно конечному потребителю. Один SELECT без JOIN-ов возвращает готовый объект для UI или API. За это платят небольшой задержкой обновления (данные чуть отстают от реального состояния) и дополнительной инфраструктурой.
Что такое read-model простыми словами
Представьте, что у вас есть три таблицы: order, order_item и customer. Каждый раз, когда нужно показать список заказов пользователя с именем клиента и суммой — делается JOIN по всем трём. Если заказов миллионы, это дорого.
Read-model — это четвёртая таблица order_summary, куда заранее складывается всё нужное: имя клиента, сумма позиций, статус. Запрос к ней — простой SELECT WHERE customer_id = $1, без JOIN-ов.
Эта таблица не редактируется напрямую. Она обновляется автоматически, когда меняется write-сторона — через события.
Где хранить read-model
Хранилище выбирают под конкретный паттерн чтения, а не «чтобы было одно на всё».
| Паттерн чтения | Хранилище |
|---|---|
| Список с пагинацией, фильтром, сортировкой | Денормализованная PG-таблица |
| Тяжёлые агрегации (миллионы строк) | PG materialized view |
| Горячий поиск по ключу (по ID) | Redis |
| Полнотекстовый поиск, фильтры с ранжированием | ElasticSearch / OpenSearch |
PG-таблица — первый шаг
Денормализованная таблица в той же базе данных — почти всегда первое решение. Никакой новой инфраструктуры.
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);
Поле version нужно, чтобы consumer не перезаписал более свежие данные устаревшим событием — об этом подробнее в разделе про обновление через события.
PG materialized view — для тяжёлых агрегаций
Когда нужна сводка вроде «оборот по продуктам за месяц», которая пересчитывается редко:
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 MATERIALIZED VIEW CONCURRENTLY по расписанию через @nestjs/schedule или по событию OrderConfirmed.
Redis — для горячих ключей
Когда одна и та же запись читается на каждый запрос:
// adapters/out/persistence/redis-subscription-view.repository.ts
@Injectable()
export class RedisSubscriptionViewRepository implements SubscriptionViewRepository {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
async findByCustomer(customerId: CustomerId): Promise<SubscriptionPlan | null> {
const raw = await this.redis.get(`customer:${customerId.value}:plan`);
return raw ? JSON.parse(raw) as SubscriptionPlan : null;
}
async upsert(customerId: CustomerId, plan: SubscriptionPlan): Promise<void> {
await this.redis.set(
`customer:${customerId.value}:plan`,
JSON.stringify(plan),
'EX', 3600,
);
}
}
Важный момент: read-model в Redis — это источник ответа, а не кеш. Кеш — fallback; read-model — первичное хранилище для этого паттерна чтения.
ElasticSearch — для полнотекстового поиска
Когда нужны фильтры по многим полям с ранжированием по релевантности:
// adapters/out/search/elasticsearch-product-view.repository.ts
@Injectable()
export class ElasticsearchProductViewRepository implements ProductSearchRepository {
constructor(@Inject(ES_CLIENT) private readonly es: Client) {}
async search(params: ProductSearchQuery): Promise<ProductSearchResult[]> {
const { hits } = await this.es.search({
index: 'products',
query: {
bool: {
must: [{ match: { name: params.q } }],
filter: [
...(params.minRating ? [{ range: { rating: { gte: params.minRating } } }] : []),
...(params.inStock ? [{ term: { in_stock: true } }] : []),
],
},
},
});
return hits.hits.map(h => toProductSearchResult(h._source));
}
}
Consumer-ы на ProductCreated, ProductPriceChanged, StockUpdated обновляют документ в индексе.
Схема read-model независима от write-стороны
Частая ошибка — проектировать read-model как копию write-таблиц. На деле схема read-model диктуется потребителем, а не агрегатом.
write-схема: read-схема (order_summary):
order(id, customer_id, status) order_summary(
order_item(order_id, qty, price) order_id,
customer(id, name, email) customer_name, ← из customer
customer_email, ← из customer
status,
item_count ← из order_item
)
В TypeScript read-DTO делается readonly — данные для чтения не меняются:
// core/order/port/out/view/order-summary.view.ts
export class OrderSummaryView {
constructor(
readonly orderId: string,
readonly customerId: string,
readonly customerName: string,
readonly status: string,
readonly itemCount: number,
readonly totalAmount: string,
readonly currency: string,
readonly createdAt: Date,
readonly confirmedAt: Date | null,
) {}
}
export function toOrderSummaryView(row: Record<string, unknown>): OrderSummaryView {
return new OrderSummaryView(
String(row['order_id']),
String(row['customer_id']),
String(row['customer_name']),
String(row['status']),
Number(row['item_count']),
String(row['total_amount']),
String(row['currency']),
new Date(row['created_at'] as string),
row['confirmed_at'] ? new Date(row['confirmed_at'] as string) : null,
);
}
ViewRepository — сырой запрос без транзакции
Read-side использует отдельный <X>ViewRepository. Он читает через DataSource.query() — без транзакции, без relations, без блокировок. Это намеренно: чтение должно быть лёгким и быстрым.
OrderViewRepository — только запросы. Для записи в read-model (consumer, восстановление) — отдельный OrderSummaryRepository.
// core/order/port/out/order-view.repository.ts
export interface OrderViewRepository {
summary(orderId: string): Promise<OrderSummaryView | null>;
listByCustomer(customerId: string, limit: number, offset: number): Promise<OrderSummaryView[]>;
}
export const ORDER_VIEW_REPOSITORY = Symbol('ORDER_VIEW_REPOSITORY');
// core/order/port/out/order-summary.repository.ts
export interface OrderSummaryRepository {
upsertBatch(rows: OrderSummaryUpsert[]): Promise<void>;
}
export const ORDER_SUMMARY_REPOSITORY = Symbol('ORDER_SUMMARY_REPOSITORY');
// adapters/out/persistence/typeorm-order-view.repository.ts
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
constructor(private readonly dataSource: DataSource) {}
async summary(orderId: string): Promise<OrderSummaryView | null> {
const rows = await this.dataSource.query(
`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 = $1`,
[orderId],
);
return rows[0] ? toOrderSummaryView(rows[0]) : null;
}
async listByCustomer(customerId: string, limit: number, offset: number): Promise<OrderSummaryView[]> {
const rows = await this.dataSource.query(
`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 = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[customerId, limit, offset],
);
return rows.map(toOrderSummaryView);
}
}
Query-handler инжектирует ORDER_VIEW_REPOSITORY и не обращается к агрегатам:
// core/order/usecase/get-order-summary.handler.ts
@Injectable()
export class GetOrderSummaryHandler implements Handler<GetOrderSummary, OrderSummaryView | null> {
constructor(
@Inject(ORDER_VIEW_REPOSITORY) private readonly orderView: OrderViewRepository,
) {}
async execute(query: GetOrderSummary): Promise<OrderSummaryView | null> {
return this.orderView.summary(query.orderId);
}
}
Обновление через события
Read-model обновляется только через события — никогда напрямую внутри write-транзакции. Схема такая:
1. command-handler сохраняет Order, записывает OrderConfirmed → outbox (одна транзакция)
2. outbox-relay публикует событие в Kafka
3. read-side consumer получает OrderConfirmed
4. UPDATE order_summary SET status = 'CONFIRMED', confirmed_at = $1, version = version + 1
WHERE order_id = $2 AND version < $3
Задержка в обычном режиме — от 100 мс до 1 секунды. Это нормально: это называется eventual consistency, и UI должен это учитывать.
Почему нельзя обновлять read-model синхронно в той же транзакции:
- Таблица read-model перестаёт быть независимой. Её структурные изменения начинают блокировать write-операции.
- При откате write-транзакции read-model уже могла поменяться — и это расхождение нечем исправить.
- Если read-model хранится в другой базе данных — синхронный sync требует двухфазного подтверждения, что крайне сложно в production.
Подробно о механике — в статье Sync через события.
Read-model всегда восстановима
Read-model — это производная от write-стороны. Это значит: если она потеряна (сбой Redis-кластера, случайный DROP TABLE) — её можно пересобрать заново, пройдя по write-side агрегатам.
Для каждой read-model должен существовать скрипт восстановления:
// core/order/service/order-summary-rebuilder.ts
@Injectable()
export class OrderSummaryRebuilder {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
@Inject(ORDER_SUMMARY_REPOSITORY) private readonly summaries: OrderSummaryRepository,
) {}
async rebuildAll(): Promise<void> {
let lastId = 0n;
const batchSize = 500;
while (true) {
const batch = await this.orders.findAllAfter(lastId, batchSize);
if (batch.length === 0) break;
const rows = batch.map(order => this.toSummaryRow(order));
await this.summaries.upsertBatch(rows);
lastId = batch[batch.length - 1].id.value;
}
}
private toSummaryRow(order: Order): OrderSummaryUpsert {
return {
orderId: order.id.value.toString(),
customerId: order.customerId.value.toString(),
customerName: order.snapshot().customerName,
customerEmail: order.snapshot().customerEmail,
status: order.status,
itemCount: order.items.length,
totalAmount: order.totalAmount.amount.toFixed(4),
currency: order.totalAmount.currency,
createdAt: order.createdAt,
confirmedAt: order.confirmedAt ?? null,
updatedAt: new Date(),
version: 0n,
};
}
}
Скрипт восстановления нужен в трёх ситуациях:
- Авария. Потеряна таблица или Redis-кластер — восстанавливаем из write-side.
- Новое хранилище. Добавили ElasticSearch — он пустой, нужно загнать существующие данные.
- Изменение схемы. Добавили новое поле в
order_summary— для старых записей восстановление дозаполнит его.
Без такого скрипта read-model де-факто становится единственным источником правды — что нарушает саму идею CQRS.
Частые ошибки
Бизнес-инварианты в read-таблице. Проверки вроде CHECK (status IN ('NEW', 'CONFIRMED')) в read-таблице — лишние. Инварианты живут в агрегате на write-стороне. Read-model — только проекция.
Read-model без возможности восстановления. Если нет скрипта восстановления, read-model становится первичным хранилищем. При потере — данные пропадают навсегда.
Запись в write-side из read-handler. Чтение и запись — строго разные направления. Query-handler только читает. Если нужно что-то изменить по результату запроса — это уже команда.
Обновление read-model внутри write-транзакции. Данные в read-model должны приходить только через события, не синхронно.
Загрузка агрегата через основной репозиторий для чтения. Агрегат несёт доменную логику и блокировки — для чтения он слишком тяжёлый. Для read-side — отдельный <X>ViewRepository с сырым запросом.
Коротко
- Read-model — данные в форме, удобной для чтения: денормализованные, без JOIN-ов на горячем пути.
- Хранилище выбирается под паттерн чтения: PG-таблица, materialized view, Redis, ElasticSearch — не одно на всё.
- Схема read-model диктуется потребителем, а не write-схемой агрегата.
- Читается через
<X>ViewRepository— сырой запрос черезDataSource.query(), без транзакции и блокировок. - Обновляется только через события (outbox → Kafka → consumer), не синхронно.
- Source of truth — write-side агрегаты. Read-model — производная, всегда восстановимая.
- Для каждой read-model обязателен скрипт восстановления: авария, новое хранилище, изменение схемы.
- Никакой бизнес-логики в read-model, никакой записи обратно в write-side из query-handler.
Что почитать дальше
- Sync через события — как outbox и Kafka доставляют события до read-model.
- Query side — как query-handler читает из read-model через
<X>ViewRepository. - Command side — что command-handler возвращает и почему не read-DTO.
- Уровень и эволюция — когда переходить от простого разделения к event-driven read-model.