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.
| Tier | What's separated | When to apply |
|---|---|---|
| Markers | Command/Query types, pgx read-only transaction | Always, starting from a non-trivial service |
| Read projection | A separate table in the same DB, a separate repository | 5+ JOINs, heavy aggregations |
| Separate stores | PG for writes, ElasticSearch or Redis for reads | A 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 BYwith dates, sums) takes a second. - The interface wants
customer_name,total_items,last_statusin 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/Querymarkers 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.
What to read next
- Command side in Go — the write handler with a
Commandmarker. - Query side in Go — the read handler with a
Querymarker andOrderViewRepository. - Read model in Go — where to store and how to update the denormalized projection.
- Tier and evolution — moving between tiers by metrics.