← Back to the section

CQRS isn't a switch you turn on or off wholesale. It's a scale: at each tier you take exactly as much separation as gives real benefit here and now. Launching with an event-driven read-model on a service with no proven load problem means paying an infrastructure price for a pain that doesn't exist.

Let's break down four tiers using an order service as an example.

Tier 1 — an ordinary service, no CQRS needed

This is the starting point. An ordinary handler service with a single repository, without separating read and write operations. No markers, the same transactions for everything.

// internal/order/service.go
type OrderService struct {
    db *pgxpool.Pool
}

func (s *OrderService) CreateOrder(ctx context.Context, customerID string, items []string) (string, error) {
    // ...write...
}

func (s *OrderService) GetOrder(ctx context.Context, id string) (OrderDTO, error) {
    // ...read, the same transactional strategy...
}

This tier suits internal utilities, thin proxies, simple CRUD services without pronounced business logic. Introducing Command/Query markers here isn't needed — they add nothing.

Tier 2 — Command and Query markers

When the service acquires a real domain with invariants, it makes sense to explicitly separate commands and queries. This gives visibility in the code: it's immediately clear whether an operation changes state or only reads.

The markers are empty interfaces with an unexported method. The unexported method works like a lock: you can't accidentally implement the interface from another package.

// core/cqrs/cqrs.go
package cqrs

type Command interface{ isCommand() }
type Query   interface{ isQuery()   }
// core/order/command/create_order.go
type CreateOrder struct {
    CustomerID string
    Items      []string
}
func (CreateOrder) isCommand() {}

// core/order/query/get_order_summary.go
type GetOrderSummary struct {
    OrderID string
}
func (GetOrderSummary) isQuery() {}

The repository at this tier stays single, but the command and query handlers get different transactional strategies.

The command handler — an ordinary write transaction:

func (h *CreateOrderHandler) Handle(ctx context.Context, cmd command.CreateOrder) (string, error) {
    var id string
    err := h.uow.Within(ctx, func(ctx context.Context) error {
        order := NewOrder(cmd.CustomerID, cmd.Items)
        if err := h.orders.Save(ctx, order); err != nil {
            return fmt.Errorf("save order: %w", err)
        }
        id = order.ID
        return nil
    })
    return id, err
}

The query handler — a read-only transaction (pgx.ReadOnly). Without this option the Query marker guarantees nothing: the code looks nice, but the database is in no way protected from an accidental write.

func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q query.GetOrderSummary) (OrderSummaryDTO, error) {
    tx, err := h.db.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
    if err != nil {
        return OrderSummaryDTO{}, fmt.Errorf("begin read tx: %w", err)
    }
    defer tx.Rollback(ctx)

    summary, err := h.orders.SummaryByID(ctx, q.OrderID)
    if err != nil {
        return OrderSummaryDTO{}, fmt.Errorf("read summary %s: %w", q.OrderID, err)
    }
    return summary, nil
}

A single OrderRepository at this tier is a deliberate simplification, not a flaw. Splitting interfaces earlier than needed is premature complexity.

// core/order/port/out/order_repository.go
type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    SummaryByID(ctx context.Context, id string) (OrderSummaryDTO, error)
    Save(ctx context.Context, o *Order) error
}

PostgreSQL itself will catch a write attempt in a ReadOnly transaction and return an error — no additional code is needed.

Tier 3 — separate repositories

When complex projections appear — transaction history, per-customer summaries, tables with filters — it becomes inconvenient to keep read and write methods in a single interface. The reading part needs separate queries, separate DTOs, its own shape of data.

The solution: introduce two interfaces.

// core/order/port/out/order_repository.go — write only
type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, o *Order) error
}

// core/order/port/out/order_view_repository.go — read only
type OrderViewRepository interface {
    SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
    ListByCustomer(ctx context.Context, customerID string, p Pagination) ([]view.OrderSummaryDTO, error)
}

Read-DTOs are self-contained structures whose shape is dictated by what the API needs, not by how the aggregate is built:

// core/order/dto/view/order_summary.go
type OrderSummaryDTO struct {
    OrderID      string
    CustomerName string
    TotalAmount  int64
    Status       string
    ItemCount    int
    CreatedAt    time.Time
}

The query handler now works with OrderViewRepository, not the main repository:

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)

    return h.views.SummaryByID(ctx, q.OrderID)
}

At this tier the database is still one — PostgreSQL. The separation so far is only in the types and interfaces, not in the infrastructure.

Tier 3 event-driven — a separate store for reading

The move is needed when the read load starts getting in the write side's way — or when the shape of the read data is fundamentally different: full-text search, analytical summaries, data from several sources.

The read model moves to a separate denormalized table (order_summary) or to Redis, and is synced via outbox + Kafka.

write side:                         read side:
  PostgreSQL                          order_summary (PG / Redis)
  ├── orders (aggregate)              └── denormalized schema
  └── outbox                              with indexes for queries
       ↓
  outbox relay (goroutine)
       ↓
  Kafka (order.events)
       ↓
  read-side consumer
       ↓
  UPSERT order_summary

The OrderViewRepository interface doesn't change — only the adapter implementation changes: now it reads from order_summary, not from orders.

// adapters/out/persistence/order_summary_repository.go
func (r *OrderSummaryRepository) SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error) {
    row, err := r.queries.GetOrderSummary(ctx, id)   // sqlc → order_summary
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return view.OrderSummaryDTO{}, fmt.Errorf("order summary not found: %w", pgx.ErrNoRows)
        }
        return view.OrderSummaryDTO{}, fmt.Errorf("get order summary: %w", err)
    }
    return toOrderSummaryDTO(row), nil
}

This tier carries a real cost:

  • Sync delay — normally 100 ms–2 s. The read side sees not the freshest data.
  • New failure points — the consumer can fall behind, the outbox can stall, the read and write data can drift apart.
  • More components — outbox relay, Kafka consumer, lag monitoring.

Below this threshold a read replica + cache solve it more cheaply and without these risks.

What the evolution looks like in practice

The order service goes through these tiers gradually, and each step is dictated by real pain, not by a plan to "do it right from the start":

  1. Tier 1 — started as an internal CRUD service.
  2. Tier 2 — an "Order" domain with invariants appeared. Added Command/Query markers and pgx.ReadOnly on the read handlers.
  3. Tier 3 split — complex projections were needed (history, per-customer summaries). Extracted OrderViewRepository with separate queries.
  4. Tier 3 event-driven — the p95 latency of list queries breached the SLA under load. Moved the read model to a separate table, synced via outbox + Kafka.

Going back (simplifying the structure) happens when services are merged or the product is scaled down — that's rare, not an ordinary refactor.

Common mistakes

Markers without a ReadOnly transaction. The Query marker on a struct guarantees nothing if the handler opens an ordinary rw transaction. Either add pgx.ReadOnly or don't use markers — a half-measure is worse than either extreme.

An event-driven read-model with a single repository. If the read model lives in a separate table but there's one OrderRepository serving both reads and writes — the separation has lost its meaning. You need a separate OrderViewRepository.

Jumping over tiers. Moving from tier 1 straight to event-driven without intermediate steps is maximum complexity without a proven need. Every step should be justified by an observed problem.

Read methods in the main repository at tier 3. If SummaryByID and ListByCustomer stayed in OrderRepository after extracting OrderViewRepository — the interface separation is formal, not real.

In short

  • CQRS is a scale of four tiers; starting from the top without justification is premature complexity.
  • Tier 1: an ordinary service, markers not needed.
  • Tier 2: Command/Query markers, necessarily with pgx.ReadOnly on the query handlers — one without the other is pointless.
  • Tier 3 split: two interfaces — OrderRepository (write, aggregate) and OrderViewRepository (read, read-DTO). The database is still one.
  • Tier 3 event-driven: the read model in a separate store, synced via outbox + Kafka. Carries sync delay and new failure points.
  • Move up by metrics and real pain, not because "that's the right way".