Most applications start with a simple mental model: one database, one set of tables, and CRUD operations that read and write to the same schema. This works beautifully until it doesn’t. The moment your read patterns diverge from your write patterns — complex search queries on one side, strict validation rules on the other — the single-model approach starts to fracture. Queries get slow because the normalized schema that’s great for writes is terrible for reads. Writes get complex because the denormalized views that make reads fast create update anomalies.
This is the gap that CQRS (Command Query Responsibility Segregation) fills. Instead of forcing a single data model to serve both reading and writing, CQRS splits them apart entirely. Commands mutate state. Queries return data. They never share a model.
Let’s look at what this means in practice, when it’s worth the added complexity, and how to implement it cleanly in Go.
The Core Idea: Separate Models for Reads and Writes
In a traditional CRUD architecture, the same data model serves both sides. Your Order struct is used to create an order, update its status, list orders by customer, and generate a revenue report. Every new query requirement risks complicating the write model, and every new write rule risks breaking existing queries.
CQRS replaces this with two distinct models:
- Write model (command side): Optimized for validation, business rule enforcement, and consistency. This is where your domain logic lives. It accepts commands, applies them through an aggregate, and produces events.
- Read model (query side): Optimized for query performance. Denormalized tables, materialized views, search indexes — whatever shape makes your queries fast. It’s built from the events the write model emits.
When CQRS Makes Sense (and When It Doesn’t)
CQRS is not a default architecture. It adds operational complexity — two data stores, synchronization logic, eventual consistency. You should reach for it when the mismatch between read and write workloads is genuinely painful:
- Read-heavy systems with complex queries: Dashboards, search interfaces, reporting tools where you need to join and filter across many dimensions.
- Collaborative domains: Multiple users acting on the same data where conflict resolution and audit trails matter.
- Write-heavy systems with heavy validation: Order processing, financial transactions — where the write model needs rich domain rules but the read side just needs fast projections.
- When you need independent scaling: Read traffic 10x higher than write traffic? Scale the read model independently without over-provisioning your write path.
Conversely, if your application is a straightforward CRUD tool with balanced read/write patterns, CQRS is overkill. A blog CMS or a simple admin panel will not benefit from the split — you’ll just maintain two models for no gain.
Implementing the Command Side in Go
The command side is pure domain logic. It receives a command, validates it, mutates state through an aggregate, and emits events. Here’s a clean implementation:
// Command represents an intent to change state.
// Commands are imperative: "PlaceOrder", not "SetOrderStatusToPlaced".
type PlaceOrder struct {
OrderID string
CustomerID string
Items []OrderItem
}
type OrderItem struct {
ProductID string
Quantity int
Price decimal.Decimal
}
// DomainEvent is something that happened in the past.
// The write model emits these; the read model consumes them.
type DomainEvent interface {
EventName() string
}
type OrderPlaced struct {
OrderID string
CustomerID string
Items []OrderItem
OccurredAt time.Time
}
func (e OrderPlaced) EventName() string { return "order.placed" }
// OrderAggregate is the consistency boundary.
// All business rules are enforced here.
type OrderAggregate struct {
id string
status string
items []OrderItem
events []DomainEvent
}
func NewOrderAggregate(id string) *OrderAggregate {
return &OrderAggregate{id: id, status: "draft"}
}
func (a *OrderAggregate) Handle(cmd PlaceOrder) error {
if a.status != "draft" {
return fmt.Errorf("order %s is already placed", a.id)
}
if len(cmd.Items) == 0 {
return errors.New("order must contain at least one item")
}
for _, item := range cmd.Items {
if item.Quantity <= 0 {
return fmt.Errorf("invalid quantity for product %s", item.ProductID)
}
}
// Apply state change and record the event
a.items = cmd.Items
a.status = "placed"
a.events = append(a.events, OrderPlaced{
OrderID: a.id,
CustomerID: cmd.CustomerID,
Items: cmd.Items,
OccurredAt: time.Now().UTC(),
})
return nil
}
// PullEvents returns and clears pending events for publishing.
func (a *OrderAggregate) PullEvents() []DomainEvent {
events := a.events
a.events = nil
return events
}
Notice that the aggregate never returns data — it only validates and mutates. The command handler orchestrates loading the aggregate, calling its method, persisting, and publishing events:
type CommandHandler struct {
repo OrderRepository
eventBus EventBus
}
func (h *CommandHandler) HandlePlaceOrder(ctx context.Context, cmd PlaceOrder) error {
// Load aggregate
agg, err := h.repo.Load(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("loading order: %w", err)
}
// Execute command
if err := agg.Handle(cmd); err != nil {
return err
}
// Persist
if err := h.repo.Save(ctx, agg); err != nil {
return fmt.Errorf("saving order: %w", err)
}
// Publish events to update read models
for _, event := range agg.PullEvents() {
if err := h.eventBus.Publish(ctx, event); err != nil {
// Log but don't fail the write — events can be replayed
log.Printf("failed to publish event: %v", err)
}
}
return nil
}
Building the Read Model: Projections
The read model is a projection — a shaped view of the data built from events. Each event handler updates a denormalized table optimized for a specific query. For an order system, you might have separate read models for "order list by customer" and "daily revenue report":
// OrderListItem is a read-optimized view — flat, denormalized,
// no business logic, no validation.
type OrderListItem struct {
OrderID string
CustomerID string
CustomerName string // joined from customer table at projection time
ItemCount int
TotalAmount decimal.Decimal
Status string
PlacedAt time.Time
}
// OrderListProjection listens for events and updates the read store.
type OrderListProjection struct {
db *sql.DB
}
func (p *OrderListProjection) Handle(ctx context.Context, event DomainEvent) error {
switch e := event.(type) {
case OrderPlaced:
var total decimal.Decimal
for _, item := range e.Items {
total = total.Add(item.Price.Mul(decimal.NewFromInt(int64(item.Quantity))))
}
_, err := p.db.ExecContext(ctx,
`INSERT INTO order_list_view (order_id, customer_id, item_count, total_amount, status, placed_at)
VALUES ($1, $2, $3, $4, 'placed', $5)`,
e.OrderID, e.CustomerID, len(e.Items), total, e.OccurredAt)
return err
}
return nil
}
The read table (order_list_view) has no foreign keys to the write model's tables. It's a completely independent store — you can use PostgreSQL for writes and Elasticsearch for reads, or keep both in the same database with separate table sets. The projection rebuilds the view from scratch whenever needed, which makes the read model disposable: corrupt it, and you replay events to regenerate it.
Eventual Consistency: The Tradeoff
The write model and read model are not synchronized in the same transaction. After a command succeeds, the read model updates asynchronously via the event bus. This means a user might place an order and not see it in their order list for a few hundred milliseconds. For most systems this is acceptable — the latency is typically sub-second and the user experience is indistinguishable from synchronous updates.
Where it matters, you can mitigate by having the API layer return the command result directly without waiting for the projection to catch up, or by implementing a read-after-write check that polls the read model briefly before returning a stale response. But the cleaner approach is to design the UI around the eventuality: show "Order placed" immediately, then let the order list refresh naturally.
Event Sourcing: Optional but Powerful
CQRS and Event Sourcing are often discussed together, but they're independent. CQRS separates read and write models. Event Sourcing stores state as a sequence of events rather than current state. You can do CQRS without event sourcing (persist the aggregate's current state to a write table, emit events for the read model). You can do event sourcing without CQRS (store events, rebuild aggregate on load, but use the same model for reads).
They combine powerfully because events become the natural integration point between the two sides. The write model emits events; the read model subscribes to those events and builds projections. This decoupling means you can add new read models at any time — just subscribe a new projection and replay the event history.
Common Pitfalls
- Over-applying CQRS: Don't split models for a simple CRUD admin panel. The operational cost of two stores and event synchronization is real.
- Leaking write-model concepts into queries: If your read API returns domain entities with business rules, you haven't actually separated concerns. Read models should be dumb DTOs.
- Not handling event replay: Projections must be idempotent. The same event delivered twice (which happens in any at-least-once delivery system) should produce the same result.
- Treating the event bus as reliable without verification: Use a transactional outbox or change data capture to ensure events are reliably published. A fire-and-forget publish that loses events leaves your read model permanently out of sync.
Wrapping Up
CQRS is an investment in architectural flexibility that pays off when read and write workloads diverge significantly. The command side stays clean and focused on domain rules. The query side gets the exact shape it needs for performance, unburdened by validation logic. The event stream between them becomes a durable record of what happened in your system — useful for debugging, auditing, and building new views on demand.
The key is recognizing when the mismatch between reads and writes is actually causing pain. If your normalized schema handles both well, stay with CRUD. But if you find yourself adding more and more special cases to serve both masters, the CQRS split gives each side room to breathe. For a deeper treatment of CQRS patterns, the Microsoft Cloud Design Patterns guide and Martin Fowler’s in-depth CQRS reference are both valuable resources.