ФАЗА 26. АРХИТЕКТУРНЫЕ ПАТТЕРНЫ CQRS в Go

Обязательно

Когда разделение действительно оправдано

Markers · Read-only tx · Full CQRS

Начинаем с типов команд и запросов, а не с новой инфраструктуры.

1. Маркеры различают намерение бесплатно

1 / 5

Command и Query - обычные struct с разными ответами и обработчиками. Уже они делают стороны явными без event store.

  • Команда меняет состояние.
  • Запрос только читает.
  • Обработчик имеет один контракт.
Command structChangeOrderwrite handlerQuery structGetOrderquery handler

2. Read-only транзакция защищает чтение

2 / 5

pgx read-only transaction не меняет данные и не вызывает доменные методы. Repository возвращает DTO экрана.

  • Запрос отдельно оптимизируется.
  • Нет лишних side effects.
  • Транзакция закрывается корректно.
QueryReadOnly txno domain methodsOrderViewDTOscreen shape

3. Денормализация отвечает числу JOIN

3 / 5

Отдельная витрина появляется, когда экран собирает много таблиц и измеренная задержка велика. Сначала проблема доказывается планом.

  • EXPLAIN показывает цену.
  • Проекция решает конкретный экран.
  • Обновление проекции известно.
8 JOINMeasured latencyplan + p95OrderViewone read query

4. Отдельное хранилище идёт после нагрузки

4 / 5

Kafka и вторая база появляются при разных требованиях записи и чтения, масштабировании или независимости. Это цена согласования и операций.

  • Write и read имеют разные профили.
  • События синхронизируют модели.
  • Операции идемпотентны и наблюдаемы.
Write DBEventsRead DBmeasured need

5. Двух функций бывает достаточно

5 / 5

Простой CRUD не требует маркеров, проекций и очередей. Архитектура остаётся минимальной, пока нет доказанной выгоды.

  • Один handler на операцию.
  • Одна база и транзакция.
  • Новые уровни добавляются по симптомам.
Simple serviceCreateOrderGetOrderenoughCQRS when usefuldifferent workloadsmeasured benefitnot default

Когда применять CQRS в Go-сервисе

← назад к разделу

Многие начинают с CQRS не потому, что им это нужно, а потому что читали про него и он звучит серьёзно. В итоге — два хранилища, Kafka, синхронизация, и сервис с тремя запросами в сутки. Это дорого без пользы.

CQRS — паттерн с ценой: отдельные интерфейсы, синхронизация состояния, eventual consistency. Применять его стоит тогда, когда выгода реально покрывает эту цену.

Хорошая новость: CQRS — это не всё или ничего. Между «один репозиторий для всего» и «два независимых хранилища» есть промежуточные варианты. Разберём их по порядку.

Три уровня: маркеры, денормализация, разные хранилища

Представьте шкалу. На одном конце — обычный сервис без разделения чтения и записи. На другом — полностью раздельные хранилища с Kafka между ними. На этой шкале три ступени.

УровеньЧто разделеноКогда применять
МаркерыТипы Command/Query, read-only транзакция pgxВсегда, начиная с нетривиального сервиса
Read-проекцияОтдельная таблица в той же БД, отдельный репозиторийJOIN'ов 5+, тяжёлые агрегации
Разные хранилищаPG для записи, ElasticSearch или Redis для чтенияСоотношение чтений к записям от 10:1 и выше

Движение идёт строго снизу вверх. Сначала маркеры — потом отдельная таблица — потом отдельное хранилище. Прыгать сразу на верхний уровень «на вырост» — распространённая ошибка.

Маркеры Command и Query — бесплатное разделение

Это самый простой уровень. Вы вводите два пустых интерфейса с неэкспортируемыми методами:

// core/cqrs/cqrs.go
package cqrs

type Command interface{ isCommand() }
type Query   interface{ isQuery()   }

// Маркеры для встраивания: неэкспортируемый метод из чужого пакета
// не реализовать, команда получает его через встроенную структуру
type CommandMarker struct{}
type QueryMarker struct{}

func (CommandMarker) isCommand() {}
func (QueryMarker) isQuery()     {}

Каждое действие помечается нужным типом:

// core/order/command/confirm_order.go
package command

type ConfirmOrder struct {
    cqrs.CommandMarker
    OrderID string
}

В коде:

// core/order/query/get_order_summary.go
package query

type GetOrderSummary struct {
    cqrs.QueryMarker
    OrderID string
}

Неэкспортируемый метод — пакетный замок: реализовать isCommand() может только код самого пакета cqrs. Поэтому команда в пакете command не пишет метод сама (компилятор ответит «does not implement cqrs.Command (unexported method isCommand)»), а встраивает cqrs.CommandMarker. Случайно сделать структуру командой из чужого пакета нельзя, а компилятор различает команды и запросы — их не поменяешь местами.

Что это даёт помимо типовой дисциплины:

Read-only транзакция. Query-handler открывает транзакцию pgx с режимом ReadOnly. PostgreSQL сам отклонит любой INSERT или UPDATE — без дополнительных проверок в коде.

func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q query.GetOrderSummary) (view.OrderSummaryDTO, error) {
    tx, err := h.db.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
    if err != nil {
        return view.OrderSummaryDTO{}, fmt.Errorf("begin read tx: %w", err)
    }
    defer tx.Rollback(ctx)
    ctx = withTx(ctx, tx) // репозиторий берёт транзакцию из контекста, как и на write-стороне

    return h.orders.SummaryByID(ctx, q.OrderID)
}

Строка withTx(ctx, tx) обязательна: без неё транзакция открыта зря, а запрос уходит через пул мимо неё — и защита «PostgreSQL сам отклонит запись» не работает.

Раздельные метрики. app_command_duration{handler="ConfirmOrder"} и app_query_duration{handler="GetOrderSummary"} — разные временные ряды. Легко понять, что именно медленно.

На этом уровне читают и пишут в один и тот же репозиторий. Никакой дополнительной инфраструктуры:

type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
    Save(ctx context.Context, o *Order) error
}

Денормализованная таблица — когда JOIN'ов слишком много

Маркеры помогают разграничить ответственность. Но если каждый read-запрос собирает данные из пяти JOIN'ов — это начинает давить на базу.

Триггер для перехода на следующий уровень — один из:

  • Типичный запрос собирает данные из пяти и более таблиц.
  • Агрегация по миллионам строк (GROUP BY с датами, суммами) занимает секунду.
  • Интерфейс хочет customer_name, total_items, last_status одним запросом — и собирать это каждый раз накладно.

Решение: отдельная таблица с денормализованными данными. Один SELECT по индексу — вместо цепочки JOIN'ов.

CREATE TABLE order_summary (
    order_id      uuid PRIMARY KEY,
    customer_id   uuid NOT NULL,
    customer_name text NOT NULL,
    status        text NOT NULL,
    item_count    int  NOT NULL,
    total_amount  bigint NOT NULL,
    updated_at    timestamptz NOT NULL
);
CREATE INDEX ix_order_summary_customer ON order_summary (customer_id, updated_at DESC);

Под эту таблицу выделяется отдельный интерфейс:

type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, o *Order) error
}

type OrderViewRepository interface {
    SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
    ListByCustomer(ctx context.Context, customerID string, page Pagination) ([]view.OrderSummaryDTO, error)
}

Читать из базы можно прямо в DTO:

type OrderSummaryDTO struct {
    OrderID      string
    CustomerName string
    TotalAmount  int64
    Status       string
    ItemCount    int
    UpdatedAt    time.Time
}

Хранилище одно — PostgreSQL. Пока проекция живёт в той же базе и обновляется дёшево, её можно вести той же транзакцией, что и запись, — строго согласованно и без брокера. Синхронизация через outbox + Kafka нужна, когда обновление проекции тяжёлое или проекция уезжает в другое хранилище. В обоих случаях это серьёзно проще, чем два физических хранилища.

Разные хранилища — только при измеренной проблеме

Полное разделение хранилищ оправдано в четырёх ситуациях. Именно при наличии одной из них — не «вдруг понадобится».

Соотношение чтений к записям от 10:1. Типичный интернет-магазин: один заказ — десять и более показов в истории, поиске, аналитике. При таком соотношении нагрузка чтения диктует архитектуру.

Принципиально другая структура данных. Полнотекстовый поиск по описаниям товаров с двадцатью фасетами и ранжированием — это не реляционная задача. ElasticSearch с инвертированным индексом эффективнее PostgreSQL с pg_trgm на порядок.

Нагрузка чтения превышает throughput основной базы. PostgreSQL выдерживает несколько тысяч записей в секунду, но пятьдесят тысяч чтений того же объёма требует отдельной read-инфраструктуры.

Нужно масштабировать чтение независимо. Write-база не должна страдать от read-нагрузки; Redis cluster или ElasticSearch масштабируется горизонтально.

Как выглядит роутинг при full CQRS:

// edge/order_handler.go — chi router
r.Post("/orders",         h.createOrder)     // запись → PG
r.Get("/orders",          h.searchOrders)    // чтение → ElasticSearch
r.Get("/orders/{id}",     h.getOrderSummary) // чтение → read-модель в PG

Это дорогая инфраструктура: два хранилища, мониторинг обоих, восстановление при рассинхронизации, утилиты перестройки индекса. Окупается только тогда, когда read-replica и кеш уже не справляются.

Частые ошибки

Full CQRS «на вырост» для нового сервиса. Команда тратит месяц на ElasticSearch, Kafka-consumer и утилиту перестройки. Через полгода — триста заявок в день, три запроса в секунду. Реальный объём покрылся бы одним PostgreSQL с маркерами. Вместо этого: расследования рассинхронизации, поддержка двух хранилищ, дежурства по Kafka lag.

Правило: начинайте с маркеров, измеряйте p95 latency и нагрузку на PostgreSQL, переходите на следующий уровень когда метрики пробивают SLA.

«У нас будет много чтения». Это не причина для разделения хранилищ. Причина — измеренная боль: p95 latency растёт линейно с числом записей и пробил SLA; CPU PostgreSQL на 80%+ от read-нагрузки; бизнес добавил функционал, который реляционная база не тянет.

До этого порога read-replica PostgreSQL и кеш покрывают большинство случаев.

Маркеры без read-only транзакции. Смысл маркера Query — не только типовая дисциплина, но и enforcement через транзакцию. Без pgx.TxOptions{AccessMode: pgx.ReadOnly} разделение остаётся декоративным.

Дополнительно: при первом чтении можно пропустить

Глубже: сколько стоит read-only транзакциярасширенное

BeginTx с pgx.ReadOnly — это BEGIN READ ONLY и ROLLBACK, два лишних обращения к базе на каждый запрос. На карточке товара под тысячами запросов в секунду это заметно. Дешёвый вариант той же гарантии — отдельный пул для чтения со строкой подключения ?default_transaction_read_only=on (или SET default_transaction_read_only = on в AfterConnect): каждое соединение из него отклоняет запись само, а обработчику не нужно открывать транзакцию вовсе. Такой пул заодно можно направить на реплику. Явная транзакция остаётся там, где нужно несколько согласованных чтений за один снимок.

Коротко

  • CQRS — спектр из трёх уровней, не выбор «всё или ничего».
  • Маркеры Command/Query с read-only транзакцией pgx — бесплатное разделение, применяется в любом нетривиальном сервисе.
  • Денормализованная таблица в той же БД — когда JOIN'ов пять и больше или агрегация стала дорогой.
  • Разные хранилища — только при измеренном соотношении чтений к записям от 10:1, поиске или необходимости масштабировать read независимо.
  • Начинать с разделения хранилищ «на вырост» — распространённая ошибка: неизвестно, какой будет реальная нагрузка.
  • Эволюция идёт строго снизу вверх: сначала маркеры, потом отдельная таблица, потом отдельное хранилище.
  • Маркер из чужого пакета получают встраиванием cqrs.CommandMarker, а не своим методом; read-only транзакцию репозиторий должен видеть через контекст, иначе она открыта зря.

Что почитать дальше