CQRS and Event Sourcing in Go: Building Systems That Remember Every Decision

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.

Leave a Reply

Your email address will not be published. Required fields are marked *