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

В большинстве приложений операции чтения — самые частые. Главная страница, список заказов, карточка товара — всё это запросы, которые ничего не меняют. 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.