ФАЗА 26. АРХИТЕКТУРНЫЕ ПАТТЕРНЫ Query side

Чтение без вызова доменной модели

Query struct · ViewRepository · sqlc

Экран получает DTO через отдельный read-only путь, а агрегаты не собираются ради отображения.

1. Query содержит параметры экрана

1 / 5

Фильтр, страница и сортировка описываются отдельным struct. Он не наследует поля команды и не меняет состояние.

  • Параметры валидируются.
  • Page size ограничен.
  • Назначение чтения понятно.
ListOrdersQueryCustomerID stringStatus stringLimit int // boundedRead-only by contract

2. ViewRepository возвращает DTO

2 / 5

Интерфейс чтения возвращает OrderView, а не Order aggregate. Реализация sqlc знает запрос и mapping, но не доменные методы.

  • Форма соответствует экрану.
  • Домен не загружается.
  • Ошибки остаются типизированными.
Query handlerViewRepositoryread DTO onlyOrderViewno aggregate

3. Read-only транзакция фиксирует снимок

3 / 5

pgx транзакция читает согласованные данные и завершается без commit. Сортировка и фильтр соответствуют индексам.

  • Нет записи и доменных событий.
  • Rollback или Close освобождает ресурсы.
  • План чтения отдельно измеряется.
ReadOnly txSELECT viewScan DTOCloseno writes

4. SQL чтения строится под витрину

4 / 5

Один запрос возвращает нужные колонки и связи. Он не повторяет доменную нормализацию, если витрина уже денормализована.

  • Выбираются только нужные поля.
  • JOIN ограничен требованием.
  • EXPLAIN подтверждает путь.
SELECT id, customer_id, status, totalFROM order_viewWHERE customer_id = $1ORDER BY created_at DESCLIMIT $2;

5. Ответ не проходит доменный маппер

5 / 5

DTO превращается в JSON напрямую, без вызова поведения агрегата. Ошибка чтения имеет собственный тип и не создаёт команду.

  • Сериализация без доменных правил.
  • NotFound отличается от сбоя.
  • Таймаут негативно ограничен.
OrderView DTOread modelJSON encoderHTTP responseno domain methods

Query side в CQRS на Go: как читать данные правильно

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

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

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

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

Реализовать isQuery() можно только внутри пакета core/cqrs, поэтому запрос в пакете query встраивает cqrs.QueryMarker — внешний код не может случайно что-то имплементировать:

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

type GetOrderSummary struct {
    cqrs.QueryMarker
    OrderID string
}

Запрос:

// core/order/query/list_orders_by_customer.go
package query

type ListOrdersByCustomer struct {
    cqrs.QueryMarker
    CustomerID string
    Status     string
    Page       int
    PageSize   int
}

Имена пишут в форме 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)
    ctx = withTx(ctx, tx) // репозиторий берёт транзакцию из контекста, как и на write-стороне

    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 безопасен.
  • withTx(ctx, tx) кладёт транзакцию в контекст — тот же приём, что UnitOfWork на write-стороне: репозиторий достаёт её из ctx и выполняет запрос внутри read-only транзакции. Без этой строки транзакция открыта зря, а запрос уходит через пул мимо неё — самая частая ошибка в таких обработчиках.
  • Ошибка возвращается значением, не panic.

Для пагинированного списка 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)
    ctx = withTx(ctx, tx)

    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
}

Результат читают сразу в DTO:

// 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 со встроенным cqrs.QueryMarker: метод isQuery() неэкспортируемый, объявить его может только пакет cqrs.
  • 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 никогда не вызывает доменные методы и не делает записи — всё это уходит в отдельную команду.
  • Транзакцию нужно донести до репозитория (withTx(ctx, tx)), иначе pgx.ReadOnly ничего не защищает.

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