CQRS Architecture #
Imagine a bookstore. The cashier must process every transaction carefully — validating stock, calculating totals, recording entries in the journal — one by one, with strict consistency. But on the other side, thousands of visitors want to browse the book catalog, search by genre, check ratings, see recommendations. If the cashier and the catalog use the same system — one model, one database — every complex catalog query competes with transactions for the same CPU and locks. The result: the cashier is slow, the catalog is slow, everyone waits. CQRS (Command Query Responsibility Segregation) solves this problem with an intuitive principle: separate the models and data paths for operations that change state (Commands) from operations that read state (Queries). The two have different needs — consistency vs speed, normalization vs denormalization, business validation vs data presentation — and they should be optimized independently.
The Basic Principle: Command vs Query #
In CQRS, every operation falls into one of two fundamentally different categories:
flowchart LR
subgraph CMD["Command — Changes State"]
C1["PlaceOrder"]
C2["CancelOrder"]
C3["UpdatePrice"]
C4["ProcessPayment"]
NOTE_C["✓ Changes data\\n✓ Must be valid & consistent\\n✓ Returns: void / ID\\n✗ Does not return data"]
end
subgraph QRY["Query — Reads State"]
Q1["GetOrderByID"]
Q2["ListOrdersByCustomer"]
Q3["SearchProducts"]
Q4["GetDashboardStats"]
NOTE_Q["✓ Read-only\\n✓ Optimized for performance\\n✓ Returns: data / DTO\\n✗ Does not change state"]
endThe basic rule is simple: Commands do not return domain data, Queries do not change state. This is the most fundamental separation of responsibilities.
| Aspect | Command | Query |
|---|---|---|
| Purpose | Change system state | Read system state |
| Output | void, ID, or acknowledgment | Data / DTO |
| Consistency | Must be consistent (ACID) | May be eventually consistent |
| Model | Rich domain model with business rules | Flat read model optimized for display |
| Database | Normalized, relational | Denormalized, may be NoSQL, cache, or search index |
| Scaling | Vertical usually suffices | Horizontal is easy — read replicas |
Three CQRS Implementation Levels #
CQRS can be implemented at different levels of complexity — from logical separation to full infrastructure separation:
flowchart TD
subgraph L1["Level 1: In-Process Separation\\nOne DB, separate handlers"]
CH1["CommandHandler"] --> WDB1[(Same DB)]
QH1["QueryHandler"] --> WDB1
end
subgraph L2["Level 2: Separate Model\\nOne DB, two models"]
CH2["CommandHandler\\nDomain Model"] --> WDB2[(Write DB)]
QH2["QueryHandler\\nRead Model"] --> WDB2
WDB2 -.->|"sync via trigger/event"| RDB2[(Read Model\\nViews/Cache)]
QH2 --> RDB2
end
subgraph L3["Level 3: Separate DB\\nFull infrastructure separation"]
CH3["CommandHandler"] --> WDB3[(Write DB\\nPostgreSQL)]
WDB3 -->|"event/CDC"| PROJ["Projector"]
PROJ --> RDB3[(Read DB\\nRedis/Elasticsearch)]
QH3["QueryHandler"] --> RDB3
end
L1 -->|"read traffic increases"| L2
L2 -->|"need separate scaling"| L3Level 1 is the most pragmatic starting point — separate CommandHandler from QueryHandler in code, but still use the same database. This already provides far better clarity and testability.
Level 2 adds a separate read model, still within the same database — for example, materialized views or denormalized tables synced via database triggers or background jobs.
Level 3 is full CQRS: the write database and read database are truly separate, synchronized via events or Change Data Capture (CDC).
Level 1 Implementation: In-Process CQRS #
Start here. No additional infrastructure needed — just code discipline:
// command/handler.go — all Commands and CommandHandlers
package command
import (
"context"
"errors"
"time"
)
// PlaceOrderCommand is the Command to create a new order
// ✓ Command = instruction to change state
type PlaceOrderCommand struct {
CustomerID string
Items []PlaceOrderItem
}
type PlaceOrderItem struct {
ProductID string
Quantity int
PriceCents int64
}
// PlaceOrderResult is the minimal command result — only an ID, not domain data
type PlaceOrderResult struct {
OrderID string
}
// OrderCommandHandler handles all order-related commands
type OrderCommandHandler struct {
repo OrderWriteRepository
userSvc UserValidator
}
func NewOrderCommandHandler(repo OrderWriteRepository, userSvc UserValidator) *OrderCommandHandler {
return &OrderCommandHandler{repo: repo, userSvc: userSvc}
}
// Handle processes PlaceOrderCommand — business validation lives here and in the domain
func (h *OrderCommandHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) (*PlaceOrderResult, error) {
// Validate command input
if cmd.CustomerID == "" {
return nil, errors.New("customer ID is required")
}
if len(cmd.Items) == 0 {
return nil, errors.New("an order must have at least one item")
}
// Cross-service validation
if err := h.userSvc.ValidateActive(ctx, cmd.CustomerID); err != nil {
return nil, errors.New("invalid customer: " + err.Error())
}
// Create the domain entity — business rules inside the entity
order, err := domain.NewOrder(cmd.CustomerID, toOrderItems(cmd.Items))
if err != nil {
return nil, err
}
// Save to the write model
if err := h.repo.Save(ctx, order); err != nil {
return nil, err
}
// ✓ A Command only returns an ID — it does not return full domain data
return &PlaceOrderResult{OrderID: order.ID()}, nil
}
// CancelOrderCommand
type CancelOrderCommand struct {
OrderID string
Reason string
}
func (h *OrderCommandHandler) HandleCancel(ctx context.Context, cmd CancelOrderCommand) error {
order, err := h.repo.FindByID(ctx, cmd.OrderID)
if err != nil {
return err
}
if err := order.Cancel(cmd.Reason); err != nil {
return err
}
return h.repo.Save(ctx, order)
}
// query/handler.go — all Queries and QueryHandlers
package query
import "context"
// GetOrderQuery is the Query to get one order
// ✓ Query = data read request
type GetOrderQuery struct {
OrderID string
}
// OrderDetailView is the Read Model — optimized for display, not the domain
// May contain denormalized data (product names, customer names, etc.)
type OrderDetailView struct {
OrderID string `json:"order_id"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
Items []OrderItemView `json:"items"`
TotalCents int64 `json:"total_cents"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
type OrderItemView struct {
ProductName string `json:"product_name"`
Quantity int `json:"quantity"`
PriceCents int64 `json:"price_cents"`
SubtotalCents int64 `json:"subtotal_cents"`
}
// ListOrdersByCustomerQuery
type ListOrdersByCustomerQuery struct {
CustomerID string
Page int
PageSize int
}
// OrderSummaryView is a compact Read Model for lists
type OrderSummaryView struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
ItemCount int `json:"item_count"`
CreatedAt string `json:"created_at"`
}
// OrderQueryHandler handles all order-related queries
type OrderQueryHandler struct {
readRepo OrderReadRepository
}
func NewOrderQueryHandler(readRepo OrderReadRepository) *OrderQueryHandler {
return &OrderQueryHandler{readRepo: readRepo}
}
// Handle processes GetOrderQuery — purely reading, no side effects
func (h *OrderQueryHandler) Handle(ctx context.Context, q GetOrderQuery) (*OrderDetailView, error) {
return h.readRepo.FindDetailByID(ctx, q.OrderID)
}
// HandleList processes ListOrdersByCustomerQuery
func (h *OrderQueryHandler) HandleList(
ctx context.Context,
q ListOrdersByCustomerQuery,
) ([]*OrderSummaryView, int, error) {
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 || q.PageSize > 100 {
q.PageSize = 20
}
return h.readRepo.FindSummariesByCustomer(ctx, q.CustomerID, q.Page, q.PageSize)
}
Level 3 Implementation: Separate Read Database #
For systems with high read traffic, the read model can live in a different database — Redis for fast lookups, Elasticsearch for search, or a PostgreSQL read replica with materialized views:
// infrastructure/write/postgres_order_repo.go — Write Repository
package write
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"myapp/domain"
)
type PostgresOrderWriteRepo struct {
db *sql.DB
}
var _ query.OrderWriteRepository = (*PostgresOrderWriteRepo)(nil)
func (r *PostgresOrderWriteRepo) Save(ctx context.Context, order *domain.Order) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Save the order to the write database (normalized)
_, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, customer_id, status, total_cents, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (id) DO UPDATE
SET status = $3, total_cents = $4`,
order.ID(), order.CustomerID(), order.Status(), order.TotalCents(),
)
if err != nil {
return fmt.Errorf("failed to save order: %w", err)
}
// Save the items
for _, item := range order.Items() {
_, err = tx.ExecContext(ctx,
`INSERT INTO order_items (id, order_id, product_id, quantity, price_cents)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING`,
item.ID(), order.ID(), item.ProductID(), item.Quantity(), item.PriceCents(),
)
if err != nil {
return fmt.Errorf("failed to save order item: %w", err)
}
}
// Save domain events to the outbox to be sent to the projector
for _, event := range order.PullEvents() {
payload, _ := json.Marshal(event)
_, err = tx.ExecContext(ctx,
`INSERT INTO outbox_events (id, event_name, payload, created_at, published)
VALUES ($1, $2, $3, NOW(), FALSE)`,
event.EventID(), event.EventName(), payload,
)
if err != nil {
return fmt.Errorf("failed to save outbox event: %w", err)
}
}
return tx.Commit()
}
// infrastructure/read/redis_order_repo.go — Read Repository using Redis
package read
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"myapp/query"
)
type RedisOrderReadRepo struct {
client *redis.Client
ttl time.Duration
}
func NewRedisOrderReadRepo(client *redis.Client) *RedisOrderReadRepo {
return &RedisOrderReadRepo{
client: client,
ttl: 24 * time.Hour,
}
}
func (r *RedisOrderReadRepo) FindDetailByID(
ctx context.Context,
orderID string,
) (*query.OrderDetailView, error) {
key := fmt.Sprintf("order:detail:%s", orderID)
data, err := r.client.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, fmt.Errorf("order not found: %s", orderID)
}
if err != nil {
return nil, fmt.Errorf("failed to read from redis: %w", err)
}
var view query.OrderDetailView
if err := json.Unmarshal(data, &view); err != nil {
return nil, err
}
return &view, nil
}
// Store saves the read model to Redis — called by the projector
func (r *RedisOrderReadRepo) Store(ctx context.Context, view *query.OrderDetailView) error {
key := fmt.Sprintf("order:detail:%s", view.OrderID)
data, err := json.Marshal(view)
if err != nil {
return err
}
return r.client.Set(ctx, key, data, r.ttl).Err()
}
// Invalidate removes the read model from the cache — called when an order is updated
func (r *RedisOrderReadRepo) Invalidate(ctx context.Context, orderID string) error {
key := fmt.Sprintf("order:detail:%s", orderID)
return r.client.Del(ctx, key).Err()
}
Projector: Synchronizing Write to Read Model #
The projector is the component that listens to domain events from the write side and updates the read model:
// infrastructure/projector/order_projector.go
package projector
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"time"
"myapp/events"
"myapp/query"
"myapp/infrastructure/read"
)
// OrderProjector builds and updates the read model based on domain events
type OrderProjector struct {
writeDB *sql.DB // to fetch data the read model needs
readRepo *read.RedisOrderReadRepo
interval time.Duration
}
func NewOrderProjector(writeDB *sql.DB, readRepo *read.RedisOrderReadRepo) *OrderProjector {
return &OrderProjector{
writeDB: writeDB,
readRepo: readRepo,
interval: 500 * time.Millisecond,
}
}
// Start runs the projector loop — read the outbox, update the read model
func (p *OrderProjector) Start(ctx context.Context) {
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.processOutbox(ctx)
}
}
}
func (p *OrderProjector) processOutbox(ctx context.Context) {
rows, err := p.writeDB.QueryContext(ctx,
`SELECT id, event_name, payload
FROM outbox_events
WHERE published = FALSE
ORDER BY created_at
LIMIT 50`,
)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var id, eventName string
var payload []byte
rows.Scan(&id, &eventName, &payload)
if err := p.handleEvent(ctx, eventName, payload); err != nil {
slog.Error("projector failed to handle event",
"event_id", id,
"event_name", eventName,
"error", err,
)
continue
}
p.writeDB.ExecContext(ctx,
`UPDATE outbox_events SET published = TRUE, published_at = NOW() WHERE id = $1`,
id,
)
}
}
func (p *OrderProjector) handleEvent(ctx context.Context, eventName string, payload []byte) error {
switch eventName {
case "order.placed":
var event events.OrderPlaced
json.Unmarshal(payload, &event)
return p.projectOrderPlaced(ctx, event)
case "order.cancelled":
var event events.OrderCancelled
json.Unmarshal(payload, &event)
return p.projectOrderCancelled(ctx, event)
default:
return nil // ignore irrelevant events
}
}
// projectOrderPlaced builds an OrderDetailView from the OrderPlaced event
func (p *OrderProjector) projectOrderPlaced(ctx context.Context, event events.OrderPlaced) error {
// Fetch additional data the read model needs (customer name, product names)
customerName, _ := p.fetchCustomerName(ctx, event.CustomerID)
items := make([]query.OrderItemView, len(event.Items))
for i, item := range event.Items {
productName, _ := p.fetchProductName(ctx, item.ProductID)
items[i] = query.OrderItemView{
ProductName: productName,
Quantity: item.Quantity,
PriceCents: item.PriceCents,
SubtotalCents: item.PriceCents * int64(item.Quantity),
}
}
view := &query.OrderDetailView{
OrderID: event.OrderID,
CustomerName: customerName,
Items: items,
TotalCents: event.TotalCents,
Status: "pending",
CreatedAt: event.OccurredAt.Format("2006-01-02 15:04:05"),
}
return p.readRepo.Store(ctx, view)
}
func (p *OrderProjector) projectOrderCancelled(ctx context.Context, event events.OrderCancelled) error {
// Invalidate the read model — it will be rebuilt on the next query
return p.readRepo.Invalidate(ctx, event.OrderID)
}
func (p *OrderProjector) fetchCustomerName(ctx context.Context, customerID string) (string, error) {
var name string
err := p.writeDB.QueryRowContext(ctx,
`SELECT full_name FROM users WHERE id = $1`, customerID,
).Scan(&name)
return name, err
}
func (p *OrderProjector) fetchProductName(ctx context.Context, productID string) (string, error) {
var name string
err := p.writeDB.QueryRowContext(ctx,
`SELECT name FROM products WHERE id = $1`, productID,
).Scan(&name)
return name, err
}
The overall data flow of CQRS Level 3:
sequenceDiagram
participant C as Client
participant CMD as Command Handler
participant WDB as Write DB (PostgreSQL)
participant OUT as Outbox
participant PROJ as Projector
participant RDB as Read DB (Redis)
participant QRY as Query Handler
C->>CMD: PlaceOrder(cmd)
CMD->>WDB: BEGIN TRANSACTION
CMD->>WDB: INSERT orders
CMD->>OUT: INSERT outbox_events
CMD->>WDB: COMMIT
CMD-->>C: {order_id: "xyz"}
Note over PROJ: Background process
PROJ->>OUT: SELECT unpublished events
PROJ->>WDB: Fetch customer & product names
PROJ->>RDB: STORE OrderDetailView
C->>QRY: GetOrder(order_id: "xyz")
QRY->>RDB: GET order:detail:xyz
RDB-->>QRY: OrderDetailView
QRY-->>C: OrderDetailViewDirectory Structure #
myapp/
├── command/ ← Command handlers and command structs
│ ├── place_order.go ← PlaceOrderCommand + Handler
│ ├── cancel_order.go
│ └── update_price.go
│
├── query/ ← Query handlers, query structs, and read models
│ ├── get_order.go ← GetOrderQuery + Handler
│ ├── list_orders.go
│ ├── views.go ← All Read Models (View structs)
│ └── repository.go ← Read Repository interface
│
├── domain/ ← Domain entities and business rules
│ ├── order.go
│ └── events.go
│
├── infrastructure/
│ ├── write/
│ │ └── postgres_order_repo.go ← Write Repository implementation
│ ├── read/
│ │ ├── redis_order_repo.go ← Read Repository implementation (Redis)
│ │ └── postgres_read_repo.go ← Read Repository implementation (PostgreSQL view)
│ └── projector/
│ └── order_projector.go ← Projector — syncs write to read
│
└── handler/
└── http/
└── order_handler.go ← HTTP handler separating CMD vs QRY routes
CQRS + Event Sourcing #
CQRS is often combined with Event Sourcing, where the write model does not store the current state but stores all events that ever happened:
flowchart LR
CMD["Command\\n(PlaceOrder)"] --> AGG["Aggregate\\n(Order)"]
AGG -->|"produces events"| ES[("Event Store\\n[OrderPlaced,\\nOrderConfirmed,\\nOrderShipped]")]
ES -->|"replay events"| AGG
ES -->|"publish"| PROJ["Projector"]
PROJ --> RM[("Read Model\\n(Redis / PG View)")]
QRY["Query"] --> RMWith Event Sourcing, order state is built by replaying all events:
// domain/order_sourced.go — an Order that uses Event Sourcing
type Order struct {
id string
customerID string
status OrderStatus
items []OrderItem
events []DomainEvent // accumulated events not yet persisted
}
// Apply applies one event to the order state
func (o *Order) Apply(event DomainEvent) {
switch e := event.(type) {
case OrderPlacedEvent:
o.id = e.OrderID
o.customerID = e.CustomerID
o.items = e.Items
o.status = StatusPending
case OrderConfirmedEvent:
o.status = StatusConfirmed
case OrderCancelledEvent:
o.status = StatusCancelled
}
}
// Reconstruct builds an Order from event history
func Reconstruct(events []DomainEvent) *Order {
order := &Order{}
for _, event := range events {
order.Apply(event)
}
return order
}
// Cancel produces a new event — it does not directly modify state
func (o *Order) Cancel(reason string) error {
if o.status != StatusPending {
return errors.New("only pending orders can be cancelled")
}
event := OrderCancelledEvent{
OrderID: o.id,
Reason: reason,
OccurredAt: time.Now(),
}
o.events = append(o.events, event) // record the event
o.Apply(event) // apply to local state
return nil
}
Anti-Patterns to Avoid #
// ✗ A Command returning full domain data — violates CQRS
func (h *OrderCommandHandler) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (*Order, error) {
order := buildOrder(cmd)
h.repo.Save(ctx, order)
return order, nil // ✗ Commands must not return domain objects
}
// ✓ A Command only returns an ID or void
func (h *OrderCommandHandler) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (string, error) {
order := buildOrder(cmd)
h.repo.Save(ctx, order)
return order.ID(), nil // ✓ only an ID
}
// ✗ A Query handler changing state — violates CQRS
func (h *OrderQueryHandler) GetOrder(ctx context.Context, orderID string) (*Order, error) {
order, _ := h.repo.FindByID(ctx, orderID)
order.ViewCount++ // ✗ queries must not change state
h.repo.Save(ctx, order)
return order, nil
}
// ✓ A Query handler purely reading
func (h *OrderQueryHandler) GetOrder(ctx context.Context, orderID string) (*OrderDetailView, error) {
return h.readRepo.FindDetailByID(ctx, orderID) // ✓ read-only
}
// ✗ Using a domain entity as a read model — the model is not optimal for display
func (h *OrderQueryHandler) ListOrders(ctx context.Context) ([]*domain.Order, error) {
return h.writeRepo.FindAll(ctx) // ✗ domain entities have many fields the display does not need
}
// ✓ Use a read model optimized for display
func (h *OrderQueryHandler) ListOrders(ctx context.Context) ([]*OrderSummaryView, error) {
return h.readRepo.FindSummaries(ctx) // ✓ optimized flat view
}
// ✗ One handler mixing commands and queries
type OrderService struct{}
func (s *OrderService) PlaceOrder(cmd PlaceOrderCommand) (*Order, error) { ... }
func (s *OrderService) GetOrder(id string) (*Order, error) { ... }
func (s *OrderService) ListOrders() ([]*Order, error) { ... }
// ✗ one service containing everything — hard to optimize and test separately
// ✓ Separate handlers per responsibility
type OrderCommandHandler struct { writeRepo OrderWriteRepository }
type OrderQueryHandler struct { readRepo OrderReadRepository }
When CQRS, When Not #
CQRS is a great fit if:
✓ Read traffic is much higher than write — need to scale reads independently
✓ Complex queries needing data from many entities (many table joins)
✓ A write domain rich in business rules while reads only need flat data
✓ The system already uses or will use EDA — projectors are a natural fit
✓ An audit trail or event replay is needed (combined with Event Sourcing)
Avoid CQRS for:
✗ Simple CRUD systems — the overhead is not justified
✗ Small teams not yet familiar with it — the learning curve can hinder delivery
✗ Very strict consistency requirements on all queries — eventual consistency is not acceptable
✗ Fast-changing MVPs or prototypes — the read model must be updated with every write model change
CQRS Architecture Review Checklist #
COMMAND SIDE:
□ Command objects only contain the data needed for the operation
□ Command handlers only return an ID or void — never domain objects
□ Business rules and validation live in domain entities, not handlers
□ Write repositories use domain entities
QUERY SIDE:
□ Query handlers have no side effects — purely read
□ Read models (Views) differ from domain entities — optimized for display
□ Read repositories use View structs, not domain entities
□ Queries can use a different database than writes
PROJECTOR:
□ The projector is idempotent — processing the same event twice produces the same result
□ The projector handles all events relevant to the read model
□ Projector errors do not block the write side
□ Projector lag is monitored — alert if the read model falls too far behind writes
CONSISTENCY:
□ Stakeholders understand and accept eventual consistency in the read model
□ A mechanism exists to notify clients when the read model is out of date
□ Operations needing strong consistency use the write side directly
TESTING:
□ Command handlers are tested with domain entities and mock repositories
□ Query handlers are tested with mock read repositories
□ The projector is tested with mock events and read model verification
□ Integration tests verify the end-to-end flow: command → event → projector → query
Summary #
- CQRS fundamentally separates read and write responsibilities — Commands change state and do not return domain data; Queries read data and do not change state.
- Start from Level 1 (in-process) — first separate CommandHandler from QueryHandler in code; no need for two databases right away; this alone brings huge benefits.
- Read models differ from domain entities — create View structs optimized for display (denormalized, flat, only the needed fields); do not force domain entities to serve query needs.
- Projectors build the read model from events — a background component listening to events from the write side and updating the read model; this is the bridge between the two worlds.
- Eventual consistency is a deliberate trade-off — the read model may be a few milliseconds behind the write model; acceptable for most use cases but must be communicated to stakeholders.
- Commands only return an ID or void — if the client needs data after a command, perform a separate query; this may require UX adjustments but is the heart of CQRS.
- CQRS and Event Sourcing are a powerful pair but stand alone — CQRS can be implemented without Event Sourcing; add Event Sourcing only if you need a full audit trail or time travel.
- The read side can use different technologies — Redis for fast lookups, Elasticsearch for search, ClickHouse for analytics; each query is optimized with the right tool.
- Best for read-heavy systems with a rich write domain — e-commerce, fintech, ERP; not for simple CRUD or teams unprepared for the added complexity.
- Monitor projector lag — the time difference between write and read models must always be monitored; excessive lag is a signal the projector needs scaling or optimization.