Опирается на правила: R-CQRS-QRY-1R-CQRS-QRY-4 и R-CQRS-QRY-X1R-CQRS-QRY-X3 из раздела 3. Query side.

Важно знать

  • Query — struct, реализует маркер-интерфейс Query через неэкспортируемый метод isQuery(). Без побочных эффектов.
  • Query-handler: read-only транзакция pgx (pgx.TxOptions{AccessMode: pgx.ReadOnly}), читает через <X>ViewRepository, возвращает read-DTO.
  • <X>ViewRepository — отдельный интерфейс. Не основной <X>Repository, который работает с агрегатом.
  • Read-DTO — struct в core/<bc>/dto/view/, структура под API, не под агрегат. Денормализованный, с pre-computed полями.
  • Query-handler не вызывает доменные методы (order.Confirm() и т. п.). Только read.
  • Доменные ошибки — значения с Kind() apperr.Domain; edge-middleware httperr.Write маппит в 4xx.
  • Запрещено: query делает write, грузит агрегат целиком и маппит в DTO, возвращает агрегат или внутренние типы наружу.
  • pgx само гарантирует enforcement: попытка tx.Exec(ctx, "UPDATE …") в read-only транзакции упадёт с ошибкой от сервера.

Query-side — это половина CQRS, занятая чтением. Она оптимизирована под чтение: денормализованные read-DTO, отдельный репозиторий, read-only транзакция. В Go отсутствуют дженерик-параметризованные интерфейсы со встроенным параметром результата, поэтому маркер реализуется через неэкспортируемый метод — пакетный замок, исключающий случайную реализацию извне.

Query — struct с маркером Query

R-CQRS-QRY-1: query — иммутабельный struct, реализует маркер-интерфейс Query из core/cqrs.

// core/cqrs/cqrs.go
package cqrs

type Command interface{ isCommand() }
type Query   interface{ isQuery()   }
// core/order/query/get_order_summary.go
package query

type GetOrderSummary struct {
    OrderID string
}

func (GetOrderSummary) isQuery() {}
// core/order/query/list_orders_by_customer.go
package query

type ListOrdersByCustomer struct {
    CustomerID string
    Status     string
    Page       int
    PageSize   int
}

func (ListOrdersByCustomer) isQuery() {}

Что важно:

  • Struct, без вычислений. Query — только параметры запроса, никакой логики.
  • Неэкспортируемый isQuery(). Реализовать интерфейс можно только из пакета core/cqrs или через явное объявление — случайная реализация из внешнего кода невозможна.
  • Имя в форме Get… / List… / Search…. Соответствует читающей стороне: GetOrderSummary, ListOrdersByCustomer, SearchProducts.

Структура query-handler-а

R-CQRS-QRY-2: handler получает <X>ViewRepository и пул pgx, открывает read-only транзакцию, вызывает read-метод, возвращает read-DTO.

// core/order/handler/get_order_summary_handler.go
package handler

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"

    "core/order/dto/view"
    "core/order/query"
)

type GetOrderSummaryHandler struct {
    views OrderViewRepository
    db    *pgxpool.Pool
}

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)

    summary, err := h.views.SummaryByID(ctx, tx, q.OrderID)
    if err != nil {
        return view.OrderSummaryDTO{}, fmt.Errorf("read order summary %s: %w", q.OrderID, err)
    }
    return summary, nil
}

Что важно:

  • pgx.TxOptions{AccessMode: pgx.ReadOnly}. PostgreSQL отклонит любой DML-запрос внутри этой транзакции на уровне сервера — нарушение R-CQRS-QRY-X1 приводит к ошибке, не к тихому сайд-эффекту.
  • defer tx.Rollback(ctx). Read-only транзакцию коммитить не нужно; rollback на defer безопасен.
  • OrderViewRepository — отдельный интерфейс, не основной OrderRepository. Описание — ниже.
  • Возвращает (view.OrderSummaryDTO, error). Ошибка — значение, не паника.

Для пагинированного запроса:

// core/order/handler/list_orders_by_customer_handler.go
package handler

type ListOrdersByCustomerHandler struct {
    views OrderViewRepository
    db    *pgxpool.Pool
}

func (h *ListOrdersByCustomerHandler) Handle(ctx context.Context, q query.ListOrdersByCustomer) ([]view.OrderListItemDTO, error) {
    tx, err := h.db.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
    if err != nil {
        return nil, fmt.Errorf("begin read tx: %w", err)
    }
    defer tx.Rollback(ctx)

    items, err := h.views.ListByCustomer(ctx, tx, q.CustomerID, q.Status, q.Page, q.PageSize)
    if err != nil {
        return nil, fmt.Errorf("list orders customer=%s: %w", q.CustomerID, err)
    }
    return items, nil
}

Read-DTO — денормализованный struct

R-CQRS-QRY-3: read-DTO — struct в core/<bc>/dto/view/. Структура под UI/API-нужды, не под агрегат.

// core/order/dto/view/order_summary.go
package view

import "time"

type OrderSummaryDTO struct {
    OrderID      string
    Status       string
    CustomerName string    // денормализовано — не нужен отдельный запрос к Customer
    TotalAmount  int64     // копейки/минорные единицы
    ItemCount    int       // pre-computed, не []OrderItem
    CreatedAt    time.Time
    UpdatedAt    time.Time
}
// core/order/dto/view/order_list_item.go
package view

import "time"

type OrderListItemDTO struct {
    OrderID     string
    Status      string
    TotalAmount int64
    ItemCount   int
    CreatedAt   time.Time
}

Что хорошего:

  • CustomerName денормализован. Для UI-детали заказа имя клиента уже в структуре — не нужен join к customers при чтении из read-модели.
  • ItemCount, а не []OrderItemDTO. Список заказов показывает «4 товара», не сами товары. Pre-computed int вместо среза — на порядок дешевле.
  • string для статуса, а не enum-тип агрегата. Read-DTO изолирован от доменных типов write-side — это снижает зацепление.

Расположение:

core/
└── order/
    ├── command/                       # command-structs
    ├── query/                         # query-structs
    ├── handler/                       # command- и query-handlers
    ├── dto/
    │   └── view/
    │       ├── order_summary.go       # read-DTO для детали
    │       └── order_list_item.go     # read-DTO для списка
    └── port/
        ├── order_repository.go        # write-интерфейс (агрегат)
        └── order_view_repository.go   # read-интерфейс (DTO)

ViewRepository — отдельный интерфейс

Из R-CQRS-QRY-2 следует: query-handler работает через <X>ViewRepository — интерфейс в core/<bc>/port/, реализованный в adapters/out/persistence/ через sqlc.

// core/order/port/order_view_repository.go
package port

import (
    "context"

    "github.com/jackc/pgx/v5"

    "core/order/dto/view"
)

type OrderViewRepository interface {
    SummaryByID(ctx context.Context, tx pgx.Tx, orderID string) (view.OrderSummaryDTO, error)
    ListByCustomer(ctx context.Context, tx pgx.Tx, customerID string, status string, page int, pageSize int) ([]view.OrderListItemDTO, error)
}

Реализация — sqlc-адаптер в adapters/out/persistence/:

// adapters/out/persistence/order_view_repository.go
package persistence

import (
    "context"

    "github.com/jackc/pgx/v5"

    "core/order/dto/view"
    "adapters/out/persistence/sqlc"
)

type PgOrderViewRepository struct {
    q *sqlc.Queries
}

func (r *PgOrderViewRepository) SummaryByID(ctx context.Context, tx pgx.Tx, orderID string) (view.OrderSummaryDTO, error) {
    row, err := r.q.WithTx(tx).GetOrderSummary(ctx, orderID)
    if err != nil {
        return view.OrderSummaryDTO{}, err
    }
    return view.OrderSummaryDTO{
        OrderID:      row.OrderID,
        Status:       row.Status,
        CustomerName: row.CustomerName,
        TotalAmount:  row.TotalAmount,
        ItemCount:    int(row.ItemCount),
        CreatedAt:    row.CreatedAt.Time,
        UpdatedAt:    row.UpdatedAt.Time,
    }, nil
}

Если есть отдельная денормализованная таблица order_summary (см. Read-model), sqlc-запрос становится тривиальным — один SELECT * без join:

-- db/queries/order_view.sql
-- name: GetOrderSummary :one
SELECT order_id, status, customer_name, total_amount, item_count, created_at, updated_at
FROM order_summary
WHERE order_id = $1;

Без денормализованной таблицы (Уровень 2 — lightweight CQRS) — запрос с join к customers и COUNT по order_items:

-- name: GetOrderSummary :one
SELECT
    o.id          AS order_id,
    o.status,
    c.name        AS customer_name,
    o.total_amount,
    COUNT(oi.id)  AS item_count,
    o.created_at,
    o.updated_at
FROM orders o
JOIN customers c  ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = $1
GROUP BY o.id, c.name;

Query не вызывает доменные методы

R-CQRS-QRY-4: внутри query-handler нет вызова бизнес-методов агрегата. Никаких order.Confirm(), никаких событий, никаких мутаций.

Типичный соблазн для домена Sber: «при запросе деталей платежа сохраню факт просмотра». Это отдельный command MarkPaymentViewedCommand, который контроллер вызовет явно — или фоновым goroutine, если аналитика не критична. В query-handler это не место:

// ПЛОХО — query мутирует состояние
func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q query.GetOrderSummary) (view.OrderSummaryDTO, error) {
    tx, _ := h.db.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
    defer tx.Rollback(ctx)

    summary, _ := h.views.SummaryByID(ctx, tx, q.OrderID)

    // R-CQRS-QRY-X1: попытка write внутри read-only tx → ошибка от pgx/postgres
    _, err := tx.Exec(ctx, `UPDATE orders SET last_viewed_at = now() WHERE id = $1`, q.OrderID)
    _ = err // ошибка игнорируется — ещё хуже

    return summary, nil
}

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

АнтипаттернПравилоЧто взамен
tx.Exec("UPDATE …") в query-handlerR-CQRS-QRY-X1Вынести в отдельный command
OrderRepository.ByID + маппинг в DTO вместо ViewRepositoryR-CQRS-QRY-X2OrderViewRepository.SummaryByID с минимальным набором полей
Возврат *Order или *OrderItem из query-handlerR-CQRS-QRY-X3Read-DTO struct в core/<bc>/dto/view/
Вызов order.Confirm() внутри query-handlerR-CQRS-QRY-4Отдельный command-handler
pgx.TxOptions{} (rw) вместо {AccessMode: pgx.ReadOnly}R-CQRS-QRY-2pgx.TxOptions{AccessMode: pgx.ReadOnly}
Read-DTO содержит типы из write-агрегата (*Order, Money)R-CQRS-QRY-3Примитивы или независимые value-типы в DTO

Куда дальше

  • Command side — пишущая половина: handler через агрегат, UnitOfWork, outbox.
  • Read-model — где и в каком виде хранить read-данные; денормализованная order_summary.
  • Sync через события — как read-таблица заполняется из событий write-side через outbox + Kafka.
  • Уровень и эволюция — когда один OrderRepository, когда отдельный OrderViewRepository.
  • Когда CQRS оправдан — lightweight vs. full split: когда какой уровень оправдан.