В большинстве приложений операции чтения — самые частые. Главная страница, список заказов, карточка товара — всё это запросы, которые ничего не меняют. CQRS предлагает простую идею: разделить код, который пишет, и код, который читает. Половина, которая отвечает за чтение, называется query side.
Эта статья объясняет, как query side устроен в Go: что такое Query-struct, как открывается read-only транзакция, зачем нужен отдельный ViewRepository и как выглядит read-DTO.
Зачем отдельный «читающий» слой
Представьте, что у вас один репозиторий OrderRepository, который и создаёт заказы, и читает их для списка. Когда на UI нужно показать «имя клиента», «количество товаров» и «сумму» — приходится либо подгружать весь агрегат Order и превращать его в нужную структуру, либо дописывать в репозиторий всё новые и новые методы под каждый экран.
Query side решает это чисто: у вас появляется отдельный интерфейс ViewRepository, методы которого сразу возвращают структуры под нужды конкретного экрана. Никакой лишней логики, никакого «достанем агрегат и замапим».
Query — struct с маркером
Запрос данных описывается отдельным struct. Он содержит только параметры: никакой логики, никаких методов с вычислениями. Чтобы случайно не перепутать Query с Command, в Go используют маркерный интерфейс с неэкспортируемым методом:
// core/cqrs/cqrs.go
package cqrs
type Command interface{ isCommand() }
type Query interface{ isQuery() }
Реализовать Query можно только из пакета core/cqrs — внешний код не может случайно что-то имплементировать:
// 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() {}
Имена пишут в форме Get…, List…, Search… — они отражают намерение читать, а не менять.
Query handler и read-only транзакция
Handler — это функция обработки запроса. Для query side главное правило: транзакция открывается только на чтение. Это не просто «соглашение» — PostgreSQL сам отклонит любой DML-запрос внутри read-only транзакции с ошибкой. Защита работает на уровне базы, не только в коде.
// 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, 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 сам блокирует любые изменения внутри транзакции.defer tx.Rollback(ctx)— read-only транзакцию коммитить не нужно; rollback в defer безопасен.- Ошибка возвращается значением, не паникой.
Для пагинированного списка handler выглядит аналогично:
// 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, 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
}
ViewRepository — отдельный интерфейс для чтения
У агрегата Order обычно есть OrderRepository — он умеет сохранять и восстанавливать полный агрегат. Для query side это не подходит: нам нужен облегчённый интерфейс, который сразу возвращает нужную структуру под UI.
// core/order/port/order_view_repository.go
package port
import (
"context"
"core/order/dto/view"
)
type OrderViewRepository interface {
SummaryByID(ctx context.Context, orderID string) (view.OrderSummaryDTO, error)
ListByCustomer(ctx context.Context, customerID string, status string, page int, pageSize int) ([]view.OrderListItemDTO, error)
}
Реализацию пишут в слое адаптеров через sqlc:
// adapters/out/persistence/order_view_repository.go
package persistence
import (
"context"
"core/order/dto/view"
"adapters/out/persistence/sqlc"
)
type PgOrderViewRepository struct {
q *sqlc.Queries
}
func (r *PgOrderViewRepository) SummaryByID(ctx context.Context, orderID string) (view.OrderSummaryDTO, error) {
row, err := r.q.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, 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;
Без отдельной таблицы — запрос с join:
-- 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;
Read-DTO — структура под нужды экрана
Read-DTO — это struct, который описывает данные так, как их хочет получить 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 // готовое число, а не срез товаров
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уже в структуре — не нужен отдельный запрос к сервису клиентов.ItemCount— готовое число, а не срез[]OrderItemDTO. Список заказов показывает «4 товара», не сами товары.Status— обычныйstring, а не тип из агрегата. Read-DTO не зависит от доменных типов write-стороны.
Расположение файлов:
core/
└── order/
├── command/
├── query/
├── handler/
├── dto/
│ └── view/
│ ├── order_summary.go
│ └── order_list_item.go
└── port/
├── order_repository.go # write-интерфейс (агрегат)
└── order_view_repository.go # read-интерфейс (DTO)
Query handler не трогает доменные методы
Query handler только читает. Никаких вызовов order.Confirm(), никаких событий, никаких обновлений.
Частый соблазн: «заодно сохраню факт просмотра». Это неправильно. Если нужно зафиксировать просмотр — это отдельная команда MarkOrderViewedCommand, которую контроллер вызовет явно. В query handler этому не место.
Попытка сделать запись внутри read-only транзакции закончится ошибкой от PostgreSQL:
// Так делать нельзя — query handler пытается записать данные
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, q.OrderID)
// PostgreSQL вернёт ошибку: нельзя делать UPDATE в read-only транзакции
tx.Exec(ctx, `UPDATE orders SET last_viewed_at = now() WHERE id = $1`, q.OrderID)
return summary, nil
}
Частые ошибки
Читать через основной OrderRepository, а не ViewRepository. Основной репозиторий загружает агрегат целиком. Для списка заказов это дорого и лишне — используйте OrderViewRepository.SummaryByID с минимальным набором полей.
Возвращать из handler сам агрегат *Order. Контроллер не должен знать о внутренней структуре агрегата. Возвращайте read-DTO — он изолирует API от доменной модели.
Открывать обычную (rw) транзакцию там, где достаточно read-only. PostgreSQL умеет оптимизировать read-only транзакции. Кроме того, явный ReadOnly — документация о намерении: этот код ничего не должен писать.
Коротко
- Query side — это половина CQRS, которая только читает. Никаких изменений состояния.
- Query описывается отдельным
structс неэкспортируемым методомisQuery()— маркер пакетного уровня. - Query handler открывает read-only транзакцию pgx (
AccessMode: pgx.ReadOnly) — PostgreSQL сам отклонит любую попытку записи. - Чтение идёт через
OrderViewRepository— отдельный интерфейс, не основной репозиторий агрегата. - Read-DTO —
structвcore/<bc>/dto/view/, под нужды UI/API, с денормализованными полями и предвычисленными значениями. - В read-DTO используют примитивы (
string,int64), а не типы из доменного агрегата. - Query handler никогда не вызывает доменные методы и не делает записи — всё это уходит в отдельную команду.
Что почитать дальше
- Command side — пишущая половина: handler через агрегат, UnitOfWork, outbox.
- Read-model — где и в каком виде хранить денормализованные данные.
- Синхронизация через события — как read-таблица обновляется из событий write-стороны.
- Когда CQRS оправдан — lightweight vs. full split.