← Back to the section

In most applications, read operations are the most frequent. The home page, the order list, the product card — these are all queries that change nothing. CQRS proposes a simple idea: separate the code that writes from the code that reads. The half responsible for reading is called the query side.

This article explains how the query side is built in Go: what a Query struct is, how a read-only transaction is opened, why a separate ViewRepository is needed, and what a read-DTO looks like.

Why a separate "reading" layer

Imagine you have a single OrderRepository that both creates orders and reads them for a list. When the UI needs to show the "customer name", the "number of items", and the "total" — you either have to load the whole Order aggregate and turn it into the required structure, or keep adding more and more methods to the repository for each screen.

The query side solves this cleanly: you get a separate ViewRepository interface whose methods immediately return structures tailored to a specific screen's needs. No extra logic, no "fetch the aggregate and map it".

Query — a struct with a marker

A data query is described by a separate struct. It contains only parameters: no logic, no methods with computations. So you never accidentally confuse a Query with a Command, Go uses a marker interface with an unexported method:

// core/cqrs/cqrs.go
package cqrs

type Command interface{ isCommand() }
type Query   interface{ isQuery()   }

Query can only be implemented from the core/cqrs package — external code cannot accidentally implement anything:

// 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() {}

Names are written in the form Get…, List…, Search… — they reflect the intent to read, not to change.

The query handler and the read-only transaction

The handler is the function that processes the query. For the query side the main rule is: the transaction is opened for reading only. This isn't just a "convention" — PostgreSQL itself rejects any DML query inside a read-only transaction with an error. The protection works at the database level, not only in code.

// 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
}

A few details:

  • pgx.TxOptions{AccessMode: pgx.ReadOnly} — PostgreSQL itself blocks any changes inside the transaction.
  • defer tx.Rollback(ctx) — a read-only transaction doesn't need to be committed; a rollback in a defer is safe.
  • The error is returned as a value, not a panic.

For a paginated list the handler looks similar:

// 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 — a separate interface for reading

The Order aggregate usually has an OrderRepository — it can save and restore the full aggregate. That doesn't fit the query side: we need a lightweight interface that immediately returns the structure the UI needs.

// 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)
}

The implementation is written in the adapter layer via 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
}

If there is a separate denormalized order_summary table, the SQL query is trivial:

-- name: GetOrderSummary :one
SELECT order_id, status, customer_name, total_amount, item_count, created_at, updated_at
FROM order_summary
WHERE order_id = $1;

Without a separate table — a query with a 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 — a structure tailored to the screen's needs

A read-DTO is a struct that describes data the way the UI or API wants to receive it. It doesn't mirror the aggregate's structure — it mirrors the response structure.

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

import "time"

type OrderSummaryDTO struct {
    OrderID      string
    Status       string
    CustomerName string    // already here, without an extra query to Customer
    TotalAmount  int64     // in minor units (cents)
    ItemCount    int       // a ready number, not a slice of items
    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
}

What matters here:

  • CustomerName is already in the structure — no separate query to the customers service is needed.
  • ItemCount is a ready number, not a []OrderItemDTO slice. The order list shows "4 items", not the items themselves.
  • Status is a plain string, not a type from the aggregate. The read-DTO doesn't depend on the write side's domain types.

File layout:

core/
└── order/
    ├── command/
    ├── query/
    ├── handler/
    ├── dto/
    │   └── view/
    │       ├── order_summary.go
    │       └── order_list_item.go
    └── port/
        ├── order_repository.go       # write interface (aggregate)
        └── order_view_repository.go  # read interface (DTO)

The query handler doesn't touch domain methods

The query handler only reads. No calls to order.Confirm(), no events, no updates.

A frequent temptation: "while I'm at it, I'll record the view". That's wrong. If you need to log a view — that's a separate MarkOrderViewedCommand that the controller will call explicitly. It has no place in the query handler.

An attempt to write inside a read-only transaction will end in an error from PostgreSQL:

// Don't do this — the query handler tries to write data
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 will return an error: you can't UPDATE in a read-only transaction
    tx.Exec(ctx, `UPDATE orders SET last_viewed_at = now() WHERE id = $1`, q.OrderID)

    return summary, nil
}

Common mistakes

Reading through the main OrderRepository instead of ViewRepository. The main repository loads the whole aggregate. For an order list that's expensive and unnecessary — use OrderViewRepository.SummaryByID with a minimal set of fields.

Returning the aggregate *Order itself from the handler. The controller shouldn't know about the aggregate's internal structure. Return a read-DTO — it isolates the API from the domain model.

Opening a regular (rw) transaction where read-only is enough. PostgreSQL can optimize read-only transactions. Besides, an explicit ReadOnly documents the intent: this code should write nothing.

In short

  • The query side is the half of CQRS that only reads. No state changes.
  • A query is described by a separate struct with an unexported isQuery() method — a package-level marker.
  • The query handler opens a pgx read-only transaction (AccessMode: pgx.ReadOnly) — PostgreSQL itself rejects any write attempt.
  • Reads go through OrderViewRepository — a separate interface, not the aggregate's main repository.
  • A read-DTO is a struct in core/<bc>/dto/view/, tailored to UI/API needs, with denormalized fields and precomputed values.
  • Read-DTOs use primitives (string, int64), not types from the domain aggregate.
  • The query handler never calls domain methods and never writes — all of that goes into a separate command.
  • Command side — the writing half: handler through the aggregate, UnitOfWork, outbox.
  • Read-model — where and in what form to store denormalized data.
  • Synchronization via events — how the read table is updated from the write side's events.
  • When CQRS is justified — lightweight vs. full split.