Опирается на правила: R-CQRS-RM-1R-CQRS-RM-4 и R-CQRS-RM-X1R-CQRS-RM-X3 из CQRS Style Guide → раздел 4. Read-model.

Важно знать

  • Read-model — данные в форме, удобной для чтения: денормализованные, pre-aggregated, индексированные под конкретные запросы.
  • Хранилище — то, что лучше всего подходит под нагрузку: PG-таблица, materialized view, Redis, ElasticSearch. Не одно «универсальное».
  • Schema read-model независима от write-схемы. Один атрибут может появиться в нескольких read-DTO под разными именами и в разных формах.
  • Обновляется через события, не synchronously в write-транзакции. Eventual consistency 100ms–1s — норма.
  • Read-model восстановима из write-side. Если потеряли — есть rebuild-скрипт, который проходит по агрегатам и заново строит проекцию.
  • Read-model — это проекция, не источник истины. Source of truth — всегда write-side агрегаты.
  • Никакой бизнес-логики в read-model (триггеры с CHECK-инвариантами бизнес-правил). Логика — в агрегатах.
  • Никакого bidirectional sync. Поток данных: write → events → read. Обратно — никогда.

Read-model — это денормализованное представление, в котором данные уже сложены так, как их хочет конечный потребитель: один SELECT без join'ов возвращает готовый объект для UI / API. Цена — eventual consistency и дополнительная инфраструктура; выгода — порядки разницы в latency и пропускной способности. Раскрытие раздела 4 гайда.

Где хранить read-model

R-CQRS-RM-1: выбор хранилища — функция от паттерна чтения. Не «у нас Redis, в него и положим».

Паттерн чтенияХранилищеПочему
Tabular query с пагинацией, фильтром, сортировкойДенормализованная PG-таблицаРеляционка хорошо умеет такой workload, transactional sync через outbox
Тяжёлые aggregations (GROUP BY миллионов строк)PG materialized viewPre-computed, refresh по расписанию или по событию
Key-lookup hot-keys (по ID, по короткому ключу)RedisSub-millisecond latency, horizontal scaling
Full-text search, multi-field фильтры с relevanceElasticSearch / OpenSearchInverted index, ranking, faceted search

PG-таблица — дефолт

Денормализованная таблица в той же СУБД — почти всегда первый шаг. Никакой новой инфраструктуры, синхронизация через outbox + локальный consumer.

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,
    shipped_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 — для idempotent UPDATE (см. Sync via events).

PG materialized view — для тяжёлых aggregations

Когда нужна сводка вроде «оборот по продуктам за последний месяц», и пересчитывать её каждый раз — дорого:

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 — по расписанию (REFRESH MATERIALIZED VIEW CONCURRENTLY каждые 5 минут) или по событию OrderConfirmed. Подробно — в PG → Materialized views (когда статья появится).

Redis — для hot-key lookup

Например, проекция Customer → ActiveSubscriptionPlan — читается на каждый запрос пользователя:

SET customer:42:plan {"plan":"PRO","expires_at":"2026-12-01"} EX 3600

Consumer на SubscriptionUpdated обновляет ключ. См. также Caching Style Guide — разница с обычным кешем в том, что read-model в Redis является источником ответа, не fallback'ом.

Полнотекстовый поиск по описаниям Product, фильтры по 20+ атрибутам с relevance — реляционка плохо умеет. Document:

{
  "product_id": 12345,
  "name": "Pixel 9 Pro 256GB",
  "category_path": ["Электроника", "Смартфоны", "Google"],
  "price": 99990,
  "in_stock": true,
  "rating": 4.7,
  "description": "..."
}

Consumer на ProductCreated, ProductPriceChanged, StockUpdated обновляет документ. Запрос GET /products?q=pixel&min_rating=4.5 идёт прямо в ES.

Schema независимая от write-стороны

R-CQRS-RM-2: read-схема может — и часто должна — отличаться от write-схемы. Денормализация — её главное оружие.

write-схема:                       read-схема (order_summary):
  order(id, customer_id, status)     order_summary(
  order_item(order_id, ..., qty)        order_id,
  customer(id, name, email)             customer_name,   ← денормализовано из customer
                                        customer_email,  ← денормализовано из customer
                                        status,
                                        item_count       ← pre-computed из order_item
                                     )

Что выигрывается:

  • GET без JOIN. SELECT * FROM order_summary WHERE order_id = ? вместо трёх join'ов.
  • Индексы под запросы read-стороны. Не нужно делать compromise на write-таблице — там свои индексы, тут свои.
  • Read-DTO в коде — read-схема в БД. Один-в-один маппинг, без сложного маппера.

Цена — обновление customer_name теперь должно прийти не только в customer, но и в order_summary через consumer. Это плата за чтение, и она обычно стоит того.

Обновление через события — eventual consistency

R-CQRS-RM-3: read-model не обновляется в write-транзакции. Только через outbox + Kafka + consumer.

1. command-handler меняет Order, сохраняет, регистрирует OrderConfirmed → outbox-таблица
2. outbox-relay (SKIP LOCKED loop) публикует событие в Kafka
3. read-side consumer (этого же сервиса или другого) ловит OrderConfirmed
4. UPDATE order_summary SET status = 'CONFIRMED', confirmed_at = ... WHERE order_id = ?

Latency обычно 100ms–1s в стационарном режиме. При деградации Kafka может быть больше — это архитектурно ожидаемо. UI должен это понимать (см. Sync via events → eventual consistency).

Почему не synchronous UPDATE в той же транзакции:

  • Read-model теряет decoupling. ALTER на order_summary блокирует write-транзакции.
  • При rollback order откатывается, а order_summary уже могла измениться (если consumer внешний).
  • Cross-DB synchronous sync невозможен в принципе без 2PC, который запрещён (см. Distributed Patterns).

Подробно — в Sync via events.

Read-model восстановима из write-side

R-CQRS-RM-4: для read-model должен существовать скрипт rebuild'а, который проходит по агрегатам write-side и заново строит проекцию.

@Component
@RequiredArgsConstructor
public class OrderSummaryRebuilder {

    private final OrderRepository orderRepository;
    private final OrderSummaryRepository orderSummaryRepository;

    public void rebuildAll() {
        long lastId = 0;
        int batchSize = 1000;
        while (true) {
            List<Order> batch = orderRepository.findAllAfter(lastId, batchSize);
            if (batch.isEmpty()) break;
            List<OrderSummaryRow> rows = batch.stream().map(this::toSummaryRow).toList();
            orderSummaryRepository.upsertBatch(rows);
            lastId = batch.getLast().id().value();
            log.info("rebuild progress: lastId={}", lastId);
        }
    }
}

Этот скрипт пригождается в трёх сценариях:

  1. Disaster recovery. Read-model потеряна (отказ Redis cluster, drop в ElasticSearch, миграция).
  2. Bootstrap нового read-store. Решили добавить ElasticSearch — он пустой, нужно загнать в него existing-данные.
  3. Структурная миграция read-схемы. Добавили новое поле в order_summary — для старых записей оно пустое, rebuild дозаполнит.

Без rebuild-скрипта read-model становится первичным хранилищем — что нарушает следующее правило.

Что запрещено

Read-model с бизнес-логикой

R-CQRS-RM-X1: read-model — это проекция. Никаких CHECK-constraint бизнес-правил, никаких триггеров с инвариантами.

-- ПЛОХО — бизнес-инвариант в read-таблице
ALTER TABLE order_summary ADD CONSTRAINT check_total_positive
    CHECK (total_amount > 0);
-- А если write-side создал заказ с нулём по бизнес-причине (refund),
-- consumer попытается обновить order_summary и упадёт. Read-инфраструктура
-- держит write-state заложником своих ограничений — это бомба.

Корректно: инвариант total_amount > 0 — в агрегате Order.confirm(). Read-таблица просто хранит то, что пришло.

Read-model как source-of-truth

R-CQRS-RM-X2: если данные есть только в read-model и не восстановимы из write-side — это уже не CQRS, это две разные системы без согласованного источника правды.

ПЛОХО:
  - Read-model в ElasticSearch
  - При update заказа меняется ES напрямую (через separate writer)
  - В PG `order` хранится urzная half-структура
  - Какой источник правды? ES? PG? Никто не знает.

Корректно: всегда есть один источник правды — write-side агрегаты. Read-model — производная.

Bidirectional sync

R-CQRS-RM-X3: поток данных идёт в одну сторону — write → events → read. Обратное направление (read меняет write) — запрещено.

ПЛОХО:
  GET /customers/{id}/summary
    → читает из ElasticSearch
    → видит inconsistency, дописывает в PostgreSQL через UPDATE
  Теперь read меняет write. Два источника правды, race-conditions,
  невозможность tracing «откуда это значение пришло».

Корректно: read обнаружил inconsistency → залогировать → дернуть rebuild для этой записи (или просто проигнорировать, если eventual всё равно догонит). Никаких write из read-handler.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Read-model с бизнес-логикой (CHECK / триггер инварианта)R-CQRS-RM-X1Логика в агрегате; read-model — только проекция
Read-model как единственный источник правдыR-CQRS-RM-X2Source of truth — write-side; read восстановим
Bidirectional sync (read → write)R-CQRS-RM-X3Одно направление: write → events → read
Synchronous UPDATE read-model в write-транзакцииR-CQRS-SYNC-X1Outbox + Kafka + consumer
Read-model без rebuild-скриптаR-CQRS-RM-4Rebuilder, проходящий по агрегатам
Use того же <X>Repository и для read, и для write при event-drivenR-CQRS-TIER-X2Отдельный <X>ViewRepository

Куда дальше

  • CQRS → раздел 4. Read-model — нормативные формулировки R-CQRS-RM-*.
  • Sync via events — как outbox + Kafka доставляет события до read-model.
  • Query side — как query-handler читает из read-model.
  • Уровень и эволюция — когда переходить к event-driven read-model.
  • Caching Style Guide — разница между кешем (fallback) и read-model (источник ответа).
  • Kafka Style Guide — outbox publishing pattern, retry, DLQ.
  • Distributed Patterns Style Guide — почему 2PC запрещён и нужна eventual consistency.