CQRS and Event Sourcing: Building an Order System from Scratch in Go

CQRS and Event Sourcing: Building an Order System from Scratch in Go

Most of us build CRUD services by default: a table, a model, a repository, done. This works fine until the domain complexity grows. When you need audit trails, temporal queries, or different read models for different consumers, the single-model approach starts to buckle. Two patterns that address this head-on are CQRS (Command Query Responsibility Segregation) and Event Sourcing (ES). Together, they give you a system where writes and reads are independently optimized, and every state change is an immutable record of what actually happened.

This isn’t theory for theory’s sake — it’s the architecture behind financial platforms, logistics systems, and any domain where “what happened” matters as much as “what’s current.” Let’s build a minimal but complete order management system in Go to see how these patterns work in practice.

The Core Idea: Events as the Source of Truth

In a traditional CRUD system, you overwrite state. An order’s total is $100, then it becomes $85 after a discount, and the previous value is gone. Event Sourcing flips this: instead of storing the current state, you store a sequence of events — OrderCreated, ItemAdded, DiscountApplied — and derive the current state by replaying them.

CQRS takes this further by separating the write side (commands that produce events) from the read side (projections that consume events to build optimized views). Your write model is an append-only event stream. Your read model is whatever shape makes queries fast — denormalized tables, materialized views, or even a completely different database.

Defining the Domain Events

Start with your events. In Go, a simple interface and concrete types are enough:

type Event struct {
    ID        string    `json:"id"`
    Aggregate string    `json:"aggregate_id"`
    Type      string    `json:"type"`
    Payload   json.RawMessage `json:"payload"`
    Timestamp time.Time `json:"timestamp"`
    Version   int       `json:"version"`
}

type OrderCreated struct {
    OrderID   string `json:"order_id"`
    CustomerID string `json:"customer_id"`
}

type ItemAdded struct {
    ItemID  string `json:"item_id"`
    Name    string `json:"name"`
    Price   int    `json:"price"`
    Qty     int    `json:"qty"`
}

Notice the Version field on Event. This is your optimistic concurrency control — every event increments the aggregate’s version, and commands fail if the expected version doesn’t match. No lost updates, no locking needed.

The Write Side: Commands and Aggregate Logic

Commands represent intentions. They validate business rules and produce events:

type CreateOrder struct {
    OrderID    string
    CustomerID string
}

type AddItem struct {
    OrderID string
    ItemID  string
    Name    string
    Price   int
    Qty     int
    ExpectedVersion int
}

type OrderAggregate struct {
    ID        string
    CustomerID string
    Items     []Item
    Version   int
}

func (a *OrderAggregate) Apply(event Event) error {
    switch event.Type {
    case "order_created":
        var d OrderCreated
        if err := json.Unmarshal(event.Payload, &d); err != nil {
            return err
        }
        a.ID = d.OrderID
        a.CustomerID = d.CustomerID
    case "item_added":
        var d ItemAdded
        if err := json.Unmarshal(event.Payload, &d); err != nil {
            return err
        }
        a.Items = append(a.Items, Item{
            ID:    d.ItemID,
            Name:  d.Name,
            Price: d.Price,
            Qty:   d.Qty,
        })
    }
    a.Version = event.Version
    return nil
}

func HandleAddItem(store EventStore, cmd AddItem) error {
    events, err := store.Load(cmd.OrderID)
    if err != nil {
        return err
    }
    agg := &OrderAggregate{}
    for _, e := range events {
        if err := agg.Apply(e); err != nil {
            return err
        }
    }
    // Optimistic concurrency check
    if agg.Version != cmd.ExpectedVersion {
        return fmt.Errorf("conflict: expected version %d, got %d",
            cmd.ExpectedVersion, agg.Version)
    }
    // Business rules live here
    if cmd.Qty <= 0 {
        return fmt.Errorf("quantity must be positive")
    }
    payload, _ := json.Marshal(ItemAdded{
        ItemID: cmd.ItemID, Name: cmd.Name,
        Price: cmd.Price, Qty: cmd.Qty,
    })
    event := Event{
        Aggregate: cmd.OrderID,
        Type:     "item_added",
        Payload:  payload,
        Version:  agg.Version + 1,
        Timestamp: time.Now(),
    }
    return store.Append(cmd.OrderID, event)
}

The critical part is the version check. If two concurrent requests both try to add an item at version 0, only one succeeds. The other gets a conflict error and can retry by reloading events at the new version. This is optimistic concurrency — it works without database locks and performs better under low contention.

The Event Store

The event store is the write-side persistence layer. In production, you'd use a dedicated event store database like EventStoreDB (now Kurrent) or PostgreSQL with an append-only events table. Here's what the interface looks like:

type EventStore interface {
    Append(aggregateID string, event Event) error
    Load(aggregateID string) ([]Event, error)
    Subscribe(ctx context.Context, handler func(Event) error) error
}

The Subscribe method is how projections get notified of new events. In production, this could be a Postgres LISTEN/NOTIFY channel, a NATS JetStream consumer, or a Kafka consumer group. The abstraction keeps your domain logic decoupled from the messaging infrastructure.

The Read Side: Projections

Projections listen to events and build read models optimized for queries. For an order system, you might want a orders table with pre-computed totals for listing, and a separate order_items table for detail views:

type OrderSummary struct {
    ID          string    `json:"id"`
    CustomerID  string    `json:"customer_id"`
    TotalAmount int       `json:"total_amount"`
    ItemCount   int       `json:"item_count"`
    UpdatedAt   time.Time `json:"updated_at"`
}

type OrderProjection struct {
    db *sql.DB
}

func (p *OrderProjection) Handle(event Event) error {
    switch event.Type {
    case "order_created":
        var d OrderCreated
        if err := json.Unmarshal(event.Payload, &d); err != nil {
            return err
        }
        _, err := p.db.Exec(`
            INSERT INTO orders (id, customer_id, total, item_count, updated_at)
            VALUES ($1, $2, 0, 0, $3)
            ON CONFLICT (id) DO NOTHING`,
            d.OrderID, d.CustomerID, event.Timestamp,
        )
        return err
    case "item_added":
        var d ItemAdded
        if err := json.Unmarshal(event.Payload, &d); err != nil {
            return err
        }
        _, err := p.db.Exec(`
            UPDATE orders
            SET total = total + ($1 * $2),
                item_count = item_count + $2,
                updated_at = $3
            WHERE id = $4`,
            d.Price, d.Qty, event.Timestamp, event.Aggregate,
        )
        return err
    }
    return nil
}

The beauty of projections is that they're independent. You can have multiple projections consuming the same events for different purposes: a real-time dashboard in Redis Streams, a search index in Elasticsearch, or a materialized view in Postgres. If a projection falls behind or gets corrupted, you rebuild it by replaying events from the store — no data loss, no manual migrations.

Wiring It Together

Here's how the pieces connect. The command handler writes events; the event bus notifies subscribers; projections update the read model:

func main() {
    db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    store := NewPostgresEventStore(db)
    bus := NewLocalEventBus()

    // Register projections
    projection := &OrderProjection{db: db}
    bus.Subscribe(context.Background(), projection.Handle)

    // Also publish to the event store for persistence
    bus.Subscribe(context.Background(), func(event Event) error {
        // Events are persisted via store.Append inside handlers,
        // but the bus ensures projections are notified in order
        return nil
    })

    http.Handle("POST /orders", CreateOrderHandler(store, bus))
    http.Handle("POST /orders/add-item", AddItemHandler(store, bus))
    http.Handle("GET /orders", ListOrdersHandler(db))
    http.ListenAndServe(":8080", nil)
}

For a production-grade event bus, consider NATS JetStream or Apache Kafka, which provide durable subscriptions, exactly-once delivery semantics, and the ability to replay from specific offsets. The Event Horizon library for Go provides ready-made implementations for both, along with MongoDB, Redis, and in-memory stores for testing.

When This Makes Sense (and When It Doesn't)

CQRS and Event Sourcing solve real problems, but they come with real costs. The patterns add architectural complexity — you now have two models to maintain instead of one, event versioning to manage, and eventual consistency to reason about. Here's a practical framework for deciding:

  1. Use it when you need audit trails or compliance. Regulated industries like finance and healthcare often require a complete record of every state change. Event Sourcing gives you this for free.
  2. Use it when your read and write patterns are fundamentally different. If writes are complex validation-heavy commands and reads are simple lookups or aggregations, CQRS lets you optimize each side independently.
  3. Use it when you need temporal queries. "What was this order's total at 3 PM yesterday?" is trivial with an event store — replay up to that timestamp. It's expensive or impossible with a single mutable table.
  4. Don't use it when you're building a simple CRUD app with straightforward read-write symmetry. The overhead of event stores, projections, and versioning isn't justified for a blog, a to-do list, or a basic admin panel.
  5. Don't use it when your team isn't bought in. These patterns require everyone on the team to think in terms of events and aggregates. Mixing event-sourced services with traditional CRUD in the same codebase without clear boundaries creates confusion.

Common Pitfalls to Watch For

After building several event-sourced systems, a few mistakes show up repeatedly:

  1. Giant aggregates. An "Order" that accumulates hundreds of events over months is a warning sign. If you're frequently loading thousands of events to reconstruct state, the aggregate is too large. Split it — consider "Order" with a short lifecycle, and a separate "CustomerOrderHistory" projection for long-term queries.
  2. Schema evolution without versioning. When you add a field to OrderCreated, old events won't have it. Plan for this from day one: either use schema-less payloads (like json.RawMessage) with version checks on deserialization, or adopt an upcasting pattern where old events are transformed into new shapes during replay.
  3. Ignoring event order. Projections must process events in the order they were appended. If your event bus doesn't guarantee ordering per aggregate, your read model will compute wrong totals and inconsistent state.
  4. Projection lag without monitoring. In an eventually-consistent system, the read model is always slightly behind the write model. This is fine — until it isn't. Monitor your projection lag (difference between the latest event timestamp and the last processed event) and alert when it exceeds your SLA.

Where to Go From Here

The code above is intentionally minimal to show the mechanics. For production systems, you'll want to add snapshotting (storing periodic aggregate state to avoid replaying thousands of events), sagas for cross-aggregate coordination, and dead-letter queues for failed projection processing.

The Event Horizon library is a solid starting point for Go — it provides CQRS/ES primitives, multiple event store backends (PostgreSQL, MongoDB, DynamoDB, Redis), and pluggable event buses (NATS, Kafka, Redis Streams). For the event store database itself, PostgreSQL with an append-only events table and LISTEN/NOTIFY is the simplest production-ready option — no additional infrastructure needed.

Event Sourcing and CQRS aren't silver bullets, but for the right domain — systems where state transitions carry business meaning, where auditability matters, and where read and write needs diverge — they eliminate entire categories of problems that traditional CRUD can only patch around.

Leave a Reply

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