Most CRUD applications follow a predictable pattern: a single database table serves both reads and writes, the same model validates input and shapes query results, and every optimization is a compromise between what makes inserts fast and what makes selects fast. This works beautifully — until it doesn’t. When your read-to-write ratio hits 100:1, when you need audit trails that survive schema migrations, or when multiple bounded contexts need to react to the same state change, the single-model approach starts to crack under its own weight.
Command Query Responsibility Segregation (CQRS) and Event Sourcing are two architectural patterns that address these problems by fundamentally separating the write path from the read path. They are not silver bullets, and they introduce real complexity. But for systems where auditability, read-side scalability, and domain event propagation matter, they earn their keep. Let’s walk through what these patterns actually do, when they make sense, and what a practical implementation looks like in Go.
The Problem CQRS Solves
In a traditional CRUD service, the same data model handles both commands (writes) and queries (reads). Your UserRepository has a Save() method that validates and persists, and a FindByID() method that loads and returns. This model must satisfy two masters: the write side needs normalization, constraints, and transactional integrity, while the read side wants denormalized, pre-joined views optimized for specific UI needs.
As the system grows, you end up with either a write model riddled with query-specific joins and projections, or a read model that bypasses business rules because it pulls directly from tables. CQRS resolves this tension by splitting them apart entirely. Commands flow through a write model that enforces business invariants. Queries hit a separate read model purpose-built for the access pattern at hand.
Where Event Sourcing Fits In
Event Sourcing takes the separation further: instead of storing current state, you store every state change as an immutable event. The current state is derived by replaying the event log. This gives you a complete audit trail by default, the ability to reconstruct past states at any point in time, and a natural integration point — every event can be published to downstream consumers.
The two patterns compose naturally but are independent. You can do CQRS without event sourcing (write to a normalized database, project into read-specific tables). You can do event sourcing without CQRS (rarely useful, since the append-only event log is a terrible query target). The sweet spot is using them together: event sourcing as the write-side persistence model, with projections that materialize events into query-optimized read models.
Building It in Go
Let’s build a concrete example: an order management system where orders are created, items are added, and the order is submitted. The write side uses event sourcing. The read side projects events into a denormalized view.
First, define the domain events. These are immutable facts — past-tense verbs describing what happened:
package domain
import "time"
// Event is the base interface for all domain events.
type Event interface {
EventName() string
OccurredAt() time.Time
}
type OrderCreated struct {
OrderID string
CustomerID string
Timestamp time.Time
}
func (e OrderCreated) EventName() string { return "OrderCreated" }
func (e OrderCreated) OccurredAt() time.Time { return e.Timestamp }
type ItemAdded struct {
OrderID string
ItemID string
Name string
Price float64
Quantity int
Timestamp time.Time
}
func (e ItemAdded) EventName() string { return "ItemAdded" }
func (e ItemAdded) OccurredAt() time.Time { return e.Timestamp }
type OrderSubmitted struct {
OrderID string
Total float64
Timestamp time.Time
}
func (e OrderSubmitted) EventName() string { return "OrderSubmitted" }
func (e OrderSubmitted) OccurredAt() time.Time { return e.Timestamp }
The aggregate root is the write-side model. It enforces business rules and emits events — it never mutates state directly. Each command method validates invariants, appends an event, and applies it through a state-transition method:
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
var (
ErrOrderAlreadySubmitted = errors.New("order already submitted")
ErrEmptyOrder = errors.New("cannot submit empty order")
)
type Order struct {
id string
customerID string
items map[string]LineItem
total float64
submitted bool
pendingEvents []Event
}
type LineItem struct {
ItemID string
Name string
Price float64
Quantity int
}
// NewOrder creates a new order aggregate.
func NewOrder(customerID string) *Order {
o := &Order{
id: uuid.New().String(),
customerID: customerID,
items: make(map[string]LineItem),
}
o.raise(OrderCreated{
OrderID: o.id,
CustomerID: customerID,
Timestamp: time.Now(),
})
return o
}
// AddItem adds a product to the order.
func (o *Order) AddItem(itemID, name string, price float64, qty int) error {
if o.submitted {
return ErrOrderAlreadySubmitted
}
o.raise(ItemAdded{
OrderID: o.id,
ItemID: itemID,
Name: name,
Price: price,
Quantity: qty,
Timestamp: time.Now(),
})
return nil
}
// Submit finalizes the order.
func (o *Order) Submit() error {
if o.submitted {
return ErrOrderAlreadySubmitted
}
if len(o.items) == 0 {
return ErrEmptyOrder
}
o.raise(OrderSubmitted{
OrderID: o.id,
Total: o.total,
Timestamp: time.Now(),
})
return nil
}
// raise emits an event and applies it immediately
// so invariants hold within the same call.
func (o *Order) raise(e Event) {
o.pendingEvents = append(o.pendingEvents, e)
o.apply(e)
}
// apply updates internal state from an event.
func (o *Order) apply(e Event) {
switch evt := e.(type) {
case OrderCreated:
o.id = evt.OrderID
o.customerID = evt.CustomerID
case ItemAdded:
item := LineItem{
ItemID: evt.ItemID, Name: evt.Name,
Price: evt.Price, Quantity: evt.Quantity,
}
existing, ok := o.items[evt.ItemID]
if ok {
item.Quantity = existing.Quantity + evt.Quantity
}
o.items[evt.ItemID] = item
o.total += evt.Price * float64(evt.Quantity)
case OrderSubmitted:
o.submitted = true
}
}
// PendingEvents returns events awaiting persistence.
func (o *Order) PendingEvents() []Event {
return o.pendingEvents
}
// FromEvents reconstructs an order by replaying its event history.
func FromEvents(events []Event) *Order {
o := &Order{items: make(map[string]LineItem)}
for _, e := range events {
o.apply(e)
}
o.pendingEvents = nil
return o
}
The event store is an append-only log. For production use, tools like Kurrent (formerly EventStoreDB) or PostgreSQL-based stores with optimistic concurrency are common. Here’s a simplified interface:
package store
import "context"
// EventStore persists events by aggregate ID.
type EventStore interface {
// Append stores events for an aggregate. The expectedVersion
// parameter enables optimistic concurrency: if the current
// version doesn't match, the append fails.
Append(ctx context.Context, aggregateID string, expectedVersion int, events []EventRecord) error
// Load retrieves all events for an aggregate.
Load(ctx context.Context, aggregateID string) ([]EventRecord, error)
}
type EventRecord struct {
AggregateID string
Version int
EventType string
Data []byte
}
Projections: Building the Read Model
The read model is where CQRS pays off. Each projection subscribes to events and builds a view optimized for a specific query. A OrderSummaryView for listing orders. A CustomerOrderCountView for analytics. Each evolves independently, can be rebuilt from scratch by replaying events, and can use whatever storage makes sense — Redis, Elasticsearch, or just another PostgreSQL table.
package projection
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
// OrderSummaryProjection builds a flat read model from events.
type OrderSummaryProjection struct {
db *sql.DB
}
func NewOrderSummaryProjection(db *sql.DB) *OrderSummaryProjection {
return &OrderSummaryProjection{db: db}
}
func (p *OrderSummaryProjection) Handle(ctx context.Context, record EventRecord) error {
switch record.EventType {
case "OrderCreated":
var e struct {
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
}
if err := json.Unmarshal(record.Data, &e); err != nil {
return err
}
_, err := p.db.ExecContext(ctx,
`INSERT INTO order_summary (order_id, customer_id, status, total, item_count)
VALUES ($1, $2, 'open', 0, 0)
ON CONFLICT (order_id) DO NOTHING`,
e.OrderID, e.CustomerID)
return err
case "ItemAdded":
var e struct {
OrderID string `json:"order_id"`
Price float64 `json:"price"`
Quantity int `json:"quantity"`
}
if err := json.Unmarshal(record.Data, &e); err != nil {
return err
}
_, err := p.db.ExecContext(ctx,
`UPDATE order_summary
SET total = total + $1,
item_count = item_count + 1
WHERE order_id = $2`,
e.Price*float64(e.Quantity), e.OrderID)
return err
case "OrderSubmitted":
var e struct {
OrderID string `json:"order_id"`
}
if err := json.Unmarshal(record.Data, &e); err != nil {
return err
}
_, err := p.db.ExecContext(ctx,
`UPDATE order_summary SET status = 'submitted' WHERE order_id = $1`,
e.OrderID)
return err
}
return nil
}
// QueryOrders retrieves paginated order summaries.
func (p *OrderSummaryProjection) QueryOrders(ctx context.Context, customerID string, limit, offset int) ([]OrderSummary, error) {
rows, err := p.db.QueryContext(ctx,
`SELECT order_id, customer_id, status, total, item_count
FROM order_summary
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
customerID, limit, offset)
if err != nil {
return nil, fmt.Errorf("query orders: %w", err)
}
defer rows.Close()
var results []OrderSummary
for rows.Next() {
var o OrderSummary
if err := rows.Scan(&o.OrderID, &o.CustomerID, &o.Status, &o.Total, &o.ItemCount); err != nil {
return nil, err
}
results = append(results, o)
}
return results, rows.Err()
}
type OrderSummary struct {
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
Status string `json:"status"`
Total float64 `json:"total"`
ItemCount int `json:"item_count"`
}
Wiring It Together: The Command Bus
The command handler orchestrates the flow: load the aggregate from the event store, execute the command, persist new events, then publish them to projections. This last step is where the transactional outbox pattern becomes critical — you need to atomically persist events and publish them, or risk inconsistency between the write and read models.
package command
import (
"context"
"fmt"
)
type AddItemHandler struct {
store EventStore
bus EventBus
}
func NewAddItemHandler(s EventStore, b EventBus) *AddItemHandler {
return &AddItemHandler{store: s, bus: b}
}
type AddItemCommand struct {
OrderID string
ItemID string
Name string
Price float64
Quantity int
}
func (h *AddItemHandler) Handle(ctx context.Context, cmd AddItemCommand) error {
// 1. Load existing events from the store
events, err := h.store.Load(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("load aggregate: %w", err)
}
// 2. Reconstruct the aggregate from history
order := domain.FromEvents(events)
// 3. Execute the command (business logic runs here)
if err := order.AddItem(cmd.ItemID, cmd.Name, cmd.Price, cmd.Quantity); err != nil {
return err
}
// 4. Persist new events (optimistic concurrency check)
if err := h.store.Append(ctx, cmd.OrderID, len(events), toRecords(order.PendingEvents())); err != nil {
return fmt.Errorf("persist events: %w", err)
}
// 5. Publish events to projections and downstream consumers
for _, e := range order.PendingEvents() {
h.bus.Publish(ctx, e)
}
return nil
}
When NOT to Use These Patterns
CQRS and event sourcing add cognitive overhead, operational complexity, and eventual consistency between writes and reads. Do not adopt them if:
- Your domain is simple CRUD with predictable access patterns — the added indirection buys nothing.
- Your team is small and unfamiliar with event-driven systems — the learning curve will eat your velocity.
- You need strongly consistent reads immediately after writes — projections are eventually consistent by design.
- Your read and write workloads are balanced — separate models shine when reads vastly outnumber writes.
Practical Pitfalls
Event versioning is inevitable. Once events are in the store, you cannot change their schema. Use upcasters — functions that transform old event versions into new ones during load — rather than migrations that rewrite history.
Snapshotting prevents replay overhead. Loading an aggregate with 10,000 events is slow. Periodically persist a snapshot of the aggregate state alongside the event log, and load from the latest snapshot plus subsequent events.
Idempotent projections are non-negotiable. Events may be delivered more than once (at-least-once delivery is the norm). Every projection handler must be idempotent — use event IDs or version numbers to deduplicate.
Wrapping Up
CQRS and event sourcing are architectural commitments, not quick wins. They shine in domains where auditability is mandatory (financial systems, healthcare), where read patterns diverge wildly from write patterns (e-commerce catalogs, reporting dashboards), or where multiple bounded contexts need to react to the same domain events without tight coupling.
The key insight is that separating commands from queries, and storing change rather than state, gives you building blocks that compose cleanly. New read models can be added without touching the write side. Event replay can rebuild any projection from scratch. And the event log becomes the integration spine of your system, replacing point-to-point data sharing with a single source of truth that every consumer projects into the shape it needs.