CQRS · Architecture
CQRS in Practice:
Why a Query Can't Use a Repository
CQRS sounds like an architecture decision you make once, up front. In practice it's a boundary you have to keep re-enforcing — because the easiest possible way to satisfy a new read requirement is always to reach for the write-side Repository that's already sitting right there.
CQRS — Command Query Responsibility Segregation — separates the responsibilities of writing and reading. It keeps the same underlying principles as the base architecture: the Domain layer stays independent, an Aggregate encapsulates business rules, and the Repository pattern holds. What changes is that use cases get split into an independent Command side and Query side, each with its own model.
Two Levels of CQRS
Splitting an Application Service into a Command Service and a Query Service is already a lightweight form of CQRS, and it's enough for most domains. Handler-based CQRS — splitting each use case into its own Handler struct, each holding its dependencies directly and exposing a single Handle method — is worth adopting once the Service is getting bloated with too many use cases, or once the write and read models genuinely need to be separate stores. With few use cases and a Service class that's staying simple, the lighter form is enough; don't reach for Handlers just because the pattern has a name.
internal/
domain/
order/
order.go # Aggregate — unchanged
repository.go # the Query interface + Repository (adds the write method)
application/
command/
cancel_order_handler.go # CancelOrderCommand + CancelOrderHandler (the write logic)
query/
get_orders_handler.go # GetOrdersQuery + GetOrdersHandler (the read logic)
interface/
http/
order_handler.go # holds the Command/Query Handlers, calls Handle(ctx, ...) directlyThe Rule That's Easy to State and Easy to Violate
A QueryHandler depends on a read-only interface — order.Query, not order.Repository. It queries the DB directly, with no Aggregate reconstitution.
// internal/domain/order/repository.go — the Query interface
type Query interface {
FindOrders(ctx context.Context, q FindQuery) ([]*Order, int, error)
}
// Repository adds the write method on top of Query. Because Go interfaces
// use structural typing, one implementation satisfies both — there's no
// need for two separate implementations.
type Repository interface {
Query
SaveOrder(ctx context.Context, order *Order) error
}
// internal/infrastructure/persistence/order_repository.go — the implementation
func (r *OrderRepository) FindOrders(ctx context.Context, q order.FindQuery) ([]*order.Order, int, error) {
// a query optimized for reading, with no Aggregate reconstitution
}// internal/application/query/get_orders_handler.go
type GetOrdersQuery struct {
Page int
Take int
}
type GetOrdersHandler struct {
orders order.Query
}
func NewGetOrdersHandler(orders order.Query) *GetOrdersHandler {
return &GetOrdersHandler{orders: orders}
}
func (h *GetOrdersHandler) Handle(ctx context.Context, q GetOrdersQuery) (*GetOrdersResult, error) {
orders, count, err := h.orders.FindOrders(ctx, order.FindQuery{Page: q.Page, Take: q.Take})
if err != nil {
return nil, err
}
return &GetOrdersResult{Orders: orders, Count: count}, nil
}This looks like a naming nuance — order.Query instead of order.Repository — and that's exactly what makes it easy to violate without anyone noticing. Repository embeds Query, so it satisfies the narrower interface too: the same concrete *OrderRepository that's already wired into the Command Handler, already tested, already has a FindOrders method that returns exactly what a list screen needs, will type-check just fine as the field on a Query Handler. Declaring that field as order.Repository instead of order.Query compiles, passes review, and quietly reopens a door CQRS exists to close: the read path now has write capability (SaveOrder) sitting right next to it, and the two models are no longer actually separate.
A Real Case Where This Went Wrong — and the Doc Agreed
A cross-implementation audit of this repo's five language ports once turned up this exact bug for real, and in a way worth being specific about, because it's more instructive than the abstract warning above. In the FastAPI implementation, a Query Handler was directly injected with the write-capable Repository — there was no separate read interface at all. That alone would be a straightforward fix. What made it a structural problem rather than a one-off slip was that FastAPI's own cqrs-pattern.md had documented this exact code as the correct example. The doc and the code agreed with each other and were both wrong.
That's a failure mode no "does the code match its own docs" audit can ever catch, by construction — the audit only checks agreement, and here the doc and the code were in perfect agreement about the wrong thing. It took checking the code against the root principle, not the local doc, to surface it. Three other languages had softer versions of the same drift: one Query Service had been fixed while a second, structurally identical Query Service in the same codebase was left on the old pattern; two more had already separated Command and Query functionally but named the Query interface XxxQueryRepository, which quietly reintroduces the word this pattern exists to keep out of the read path's vocabulary.
No harness rule existed yet that specifically flagged a Repository type showing up inside application/query/. Structural checks — is there a domain folder, does the interface layer avoid infrastructure imports — don't catch a wrong dependency choice one layer down. Once a rule was written for exactly this shape, it caught the same violation independently in three more languages the very first time it ran.
The Interface Layer Barely Changes
From the HTTP Handler's side, adopting CQRS is mostly a routing change — call the right Handler's Handle method instead of the right service method:
func (h *OrderHandler) CancelOrder(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("orderId")
if _, err := h.cancelOrder.Handle(r.Context(), command.CancelOrderCommand{OrderID: orderID}); err != nil {
writeOrderError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
page, take := parsePagination(r)
result, err := h.getOrders.Handle(r.Context(), query.GetOrdersQuery{Page: page, Take: take})
if err != nil {
writeOrderError(w, r, err)
return
}
writeJSON(w, r, result)
}A Domain Event still doesn't use an in-process event bus for cross-cutting follow-up work — it's delivered through the Outbox → message queue → EventConsumer path, the same as in the base architecture. CQRS changes how a single request is routed to its handler; it doesn't change how a fact that already happened gets communicated afterward.
What CQRS Doesn't Change
Both the base architecture and Handler-based CQRS keep Domain-layer independence, Aggregate encapsulation, and the Repository pattern exactly the same. CQRS is a routing and read-model decision sitting on top of that foundation, not a replacement for it — which is precisely why a Query Handler reaching for a Repository is so easy to write and so easy to miss: everything underneath it still compiles, still passes the unit tests, and still looks, at a glance, like the same architecture it's quietly no longer following.
docs/architecture/cqrs-pattern.md — the full Command/Query/Handler structure · docs/architecture/repository-pattern.md — the Repository pattern the Query side deliberately avoids