← Back to the section

Many people start with CQRS not because they need it, but because they read about it and it sounds serious. The result — two stores, Kafka, synchronization, and a service with three requests a day. That's expensive without benefit.

CQRS is a pattern with a price: separate interfaces, state synchronization, eventual consistency. It's worth applying when the benefit really covers this price.

The good news: CQRS isn't all or nothing. Between "one repository for everything" and "two independent stores" there are intermediate options. Let's go through them in order.

Three tiers: markers, denormalization, separate stores

Imagine a scale. At one end — an ordinary service without separating reads and writes. At the other — fully separate stores with Kafka between them. Between the extremes there are three intermediate points.

TierWhat's separatedWhen to apply
MarkersCommand/Query types, pgx read-only transactionAlways, starting from a non-trivial service
Read projectionA separate table in the same DB, a separate repository5+ JOINs, heavy aggregations
Separate storesPG for writes, ElasticSearch or Redis for readsA read-to-write ratio of 10:1 and higher

Movement goes strictly bottom-up. First markers — then a separate table — then a separate store. Jumping straight to the top tier "for growth" is a common mistake.

Command and Query markers — free separation

This is the simplest tier. You introduce two empty interfaces with unexported methods:

// core/cqrs/cqrs.go
package cqrs

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

Each action is tagged with the appropriate type:

// core/order/command/confirm_order.go
package command

type ConfirmOrder struct {
    OrderID string
}

func (ConfirmOrder) isCommand() {}
// core/order/query/get_order_summary.go
package query

type GetOrderSummary struct {
    OrderID string
}

func (GetOrderSummary) isQuery() {}

The unexported method is a package lock. A type from another package won't implement this interface by accident: only types in the cqrs package can do it explicitly. The compiler tells commands and queries apart, and you can't swap them.

What this gives beyond type discipline:

A read-only transaction. The query handler opens a pgx transaction in ReadOnly mode. PostgreSQL itself rejects any INSERT or UPDATE — with no extra checks in the code.

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.orders.SummaryByID(ctx, q.OrderID)
}

Separate metrics. app_command_duration{handler="ConfirmOrder"} and app_query_duration{handler="GetOrderSummary"} are different time series. It's easy to understand exactly what's slow.

At this tier, reads and writes go to the same repository. No additional infrastructure:

type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
    Save(ctx context.Context, o *Order) error
}

A denormalized table — when there are too many JOINs

Markers help delineate responsibility. But if every read query assembles data from five JOINs — that starts to press on the database.

The trigger to move to the next tier is one of:

  • A typical query gathers data from five or more tables.
  • Aggregation over millions of rows (GROUP BY with dates, sums) takes a second.
  • The interface wants customer_name, total_items, last_status in a single query — and assembling this every time is costly.

The solution: a separate table with denormalized data. A single SELECT by index — instead of a chain of JOINs.

CREATE TABLE order_summary (
    order_id      uuid PRIMARY KEY,
    customer_id   uuid NOT NULL,
    customer_name text NOT NULL,
    status        text NOT NULL,
    item_count    int  NOT NULL,
    total_amount  bigint NOT NULL,
    updated_at    timestamptz NOT NULL
);
CREATE INDEX ix_order_summary_customer ON order_summary (customer_id, updated_at DESC);

A separate interface is allocated for this table:

type OrderRepository interface {
    ByID(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, o *Order) error
}

type OrderViewRepository interface {
    SummaryByID(ctx context.Context, id string) (view.OrderSummaryDTO, error)
    ListByCustomer(ctx context.Context, customerID string, page Pagination) ([]view.OrderSummaryDTO, error)
}
type OrderSummaryDTO struct {
    OrderID      string
    CustomerName string
    TotalAmount  int64
    Status       string
    ItemCount    int
    UpdatedAt    time.Time
}

The store is one — PostgreSQL. Write → read synchronization goes through outbox + Kafka within the service. This is significantly simpler than two physical stores.

Separate stores — only with a measured problem

A full store split is justified in four situations. Precisely when one of them is present — not "in case it's needed".

A read-to-write ratio of 10:1. A typical online store: one order — ten or more views in history, search, analytics. At such a ratio the read load dictates the architecture.

A fundamentally different data structure. Full-text search over product descriptions with twenty facets and ranking isn't a relational task. ElasticSearch with an inverted index is an order of magnitude more efficient than PostgreSQL with pg_trgm.

Read load exceeds the main database's throughput. PostgreSQL handles several thousand writes per second, but fifty thousand reads of the same volume requires separate read infrastructure.

You need to scale reads independently. The write database shouldn't suffer from the read load; a Redis cluster or ElasticSearch scales horizontally.

What routing looks like with full CQRS:

// edge/order_handler.go — chi router
r.Post("/orders",         h.createOrder)     // write → PG
r.Get("/orders",          h.searchOrders)    // read → ElasticSearch
r.Get("/orders/{id}",     h.getOrderSummary) // read → read model in PG

This is expensive infrastructure: two stores, monitoring of both, recovery on desynchronization, index-rebuild utilities. It pays off only when a read replica and cache no longer cope.

Common mistakes

Full CQRS "for growth" on a new service. The team spends a month on ElasticSearch, a Kafka consumer, and a rebuild utility. Six months later — three hundred requests a day, three requests per second. The real volume would have been covered by a single PostgreSQL with markers. Instead: desync investigations, maintaining two stores, on-call duty for Kafka lag.

The rule: start with markers, measure p95 latency and PostgreSQL load, move to the next tier when the metrics breach the SLA.

"We'll have a lot of reads." This isn't a reason to split stores. The reason is measured pain: p95 latency grows linearly with the number of records and breached the SLA; PostgreSQL CPU at 80%+ from the read load; the business added functionality a relational database can't handle.

Below this threshold a PostgreSQL read replica and cache cover most cases.

Markers without a read-only transaction. The point of the Query marker isn't only type discipline, but also enforcement through the transaction. Without pgx.TxOptions{AccessMode: pgx.ReadOnly} the separation remains decorative.

In short

  • CQRS is a spectrum of three tiers, not an all-or-nothing choice.
  • Command/Query markers with a pgx read-only transaction — free separation, applied in any non-trivial service.
  • A denormalized table in the same DB — when there are five or more JOINs or aggregation has become expensive.
  • Separate stores — only with a measured read-to-write ratio of 10:1, search, or a need to scale reads independently.
  • Starting with a store split "for growth" is a common mistake: you don't know what the real load will be.
  • Evolution goes strictly bottom-up: first markers, then a separate table, then a separate store.
  • Command side in Go — the write handler with a Command marker.
  • Query side in Go — the read handler with a Query marker and OrderViewRepository.
  • Read model in Go — where to store and how to update the denormalized projection.
  • Tier and evolution — moving between tiers by metrics.