Опирается на правила:
R-CQRS-QRY-1…R-CQRS-QRY-4иR-CQRS-QRY-X1…R-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-middlewarehttperr.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-computedintвместо среза — на порядок дешевле.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-handler | R-CQRS-QRY-X1 | Вынести в отдельный command |
OrderRepository.ByID + маппинг в DTO вместо ViewRepository | R-CQRS-QRY-X2 | OrderViewRepository.SummaryByID с минимальным набором полей |
Возврат *Order или *OrderItem из query-handler | R-CQRS-QRY-X3 | Read-DTO struct в core/<bc>/dto/view/ |
Вызов order.Confirm() внутри query-handler | R-CQRS-QRY-4 | Отдельный command-handler |
pgx.TxOptions{} (rw) вместо {AccessMode: pgx.ReadOnly} | R-CQRS-QRY-2 | pgx.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: когда какой уровень оправдан.