Event Sourcing in Practice: Building a System That Never Forgets

Most systems today use CRUD: read the current state, modify a row, write it back. The database remembers where things are, not how they got there. If you need to answer “what changed between Tuesday and Thursday?” or “what did this account look like before the bug?”, you’re out of luck — that information was overwritten the moment the UPDATE ran.

Event sourcing flips this model on its head. Instead of storing current state, you store a sequence of events — immutable facts about things that happened. Current state becomes a derived projection of those events, computed by replaying the history. This gives you a complete audit trail for free, the ability to travel back to any point in time, and a clean separation between how you write data and how you read it.

In this post, we’ll build a working event-sourced system in Go — covering the append-only event store, aggregate reconstruction, optimistic concurrency, snapshots for performance, and projections for query-optimized read models.

Events and Aggregates

An event is a fact: something that already happened, expressed in the past tense. “AccountOpened”, “MoneyDeposited”, “MoneyWithdrawn” — each captures intent and outcome in one immutable record. An aggregate is a domain object (like a bank account) that rebuilds its state by replaying its events through an Apply method:

package main

import (
    "encoding/json"
    "fmt"
    "time"
)

// Event is an immutable record of something that happened.
type Event struct {
    AggregateID string          `json:"aggregate_id"`
    Type        string          `json:"type"`
    Data        json.RawMessage `json:"data"`
    Timestamp   time.Time       `json:"timestamp"`
    Version     int             `json:"version"`
}

// BankAccount is rebuilt by replaying its event stream.
type BankAccount struct {
    id      string
    owner   string
    balance int64
    version int
}

func (a *BankAccount) ID() string   { return a.id }
func (a *BankAccount) Version() int { return a.version }

// Apply mutates aggregate state based on a single event.
func (a *BankAccount) Apply(event Event) error {
    switch event.Type {
    case "AccountOpened":
        var d struct{ Owner string `json:"owner"` }
        if err := json.Unmarshal(event.Data, &d); err != nil {
            return err
        }
        a.id = event.AggregateID
        a.owner = d.Owner
    case "MoneyDeposited":
        var d struct{ Amount int64 `json:"amount"` }
        if err := json.Unmarshal(event.Data, &d); err != nil {
            return err
        }
        a.balance += d.Amount
    case "MoneyWithdrawn":
        var d struct{ Amount int64 `json:"amount"` }
        if err := json.Unmarshal(event.Data, &d); err != nil {
            return err
        }
        a.balance -= d.Amount
    default:
        return fmt.Errorf("unknown event type: %s", event.Type)
    }
    a.version = event.Version
    return nil
}

Notice the pattern: the aggregate has no public setters, no UpdateBalance method. State changes only through events. This makes every modification auditable and reproducible — you can hand someone the event stream and they can reconstruct identical state.

The Append-Only Event Store

The event store is your source of truth. Events are appended — never modified, never deleted. This is fundamentally different from a CRUD database where rows are constantly overwritten. The store also enforces optimistic concurrency: each append specifies the version the client expects, and the store rejects writes if the stream has moved on.

// EventStore persists events in an append-only log per aggregate.
type EventStore interface {
    Append(aggregateID string, events []Event, expectedVersion int) error
    Load(aggregateID string) ([]Event, error)
    LoadFrom(aggregateID string, version int) ([]Event, error)
}

type MemoryStore struct {
    streams map[string][]Event
}

func NewMemoryStore() *MemoryStore {
    return &MemoryStore{streams: make(map[string][]Event)}
}

func (s *MemoryStore) Append(aggregateID string, events []Event, expectedVersion int) error {
    current, exists := s.streams[aggregateID]
    if exists && len(current) != expectedVersion {
        return fmt.Errorf("concurrency conflict: expected version %d, got %d",
            expectedVersion, len(current))
    }
    s.streams[aggregateID] = append(current, events...)
    return nil
}

func (s *MemoryStore) Load(aggregateID string) ([]Event, error) {
    events, exists := s.streams[aggregateID]
    if !exists {
        return nil, fmt.Errorf("aggregate %q not found", aggregateID)
    }
    return events, nil
}

func (s *MemoryStore) LoadFrom(aggregateID string, version int) ([]Event, error) {
    events, exists := s.streams[aggregateID]
    if !exists {
        return nil, fmt.Errorf("aggregate %q not found", aggregateID)
    }
    if version >= len(events) {
        return []Event{}, nil
    }
    return events[version:], nil
}

In production, this same interface maps to a dedicated event store like EventStoreDB (now Kurrent), an Apache Kafka log, or even a plain PostgreSQL table with a check constraint enforcing append-only semantics. The concurrency check translates to a conditional INSERT: INSERT ... WHERE version = expected_version.

Commands: Load, Decide, Append

Commands translate user intent into events. The handler loads the aggregate by replaying its event history, applies business rules against the reconstructed state, and appends new events if the rules pass:

// AccountService handles commands by loading, deciding, and appending.
type AccountService struct {
    store EventStore
}

func NewAccountService(store EventStore) *AccountService {
    return &AccountService{store: store}
}

// Deposit loads current state, validates, and appends an event.
func (s *AccountService) Deposit(id string, amount int64) error {
    account, err := s.load(id)
    if err != nil {
        return err
    }
    if amount <= 0 {
        return fmt.Errorf("deposit amount must be positive")
    }

    data, _ := json.Marshal(struct {
        Amount int64 `json:"amount"`
    }{Amount: amount})

    event := Event{
        AggregateID: id,
        Type:        "MoneyDeposited",
        Data:        data,
        Timestamp:   time.Now(),
        Version:     account.version + 1,
    }
    return s.store.Append(id, []Event{event}, account.version)
}

// Withdraw checks balance before emitting the event.
func (s *AccountService) Withdraw(id string, amount int64) error {
    account, err := s.load(id)
    if err != nil {
        return err
    }
    if amount <= 0 {
        return fmt.Errorf("withdrawal amount must be positive")
    }
    if account.balance < amount {
        return fmt.Errorf("insufficient funds: have %d, need %d",
            account.balance, amount)
    }

    data, _ := json.Marshal(struct {
        Amount int64 `json:"amount"`
    }{Amount: amount})

    event := Event{
        AggregateID: id,
        Type:        "MoneyWithdrawn",
        Data:        data,
        Timestamp:   time.Now(),
        Version:     account.version + 1,
    }
    return s.store.Append(id, []Event{event}, account.version)
}

// load rebuilds aggregate state by replaying all events.
func (s *AccountService) load(id string) (*BankAccount, error) {
    events, err := s.store.Load(id)
    if err != nil {
        return nil, err
    }
    account := &BankAccount{}
    for _, event := range events {
        if err := account.Apply(event); err != nil {
            return nil, err
        }
    }
    return account, nil
}

Snapshots: Avoiding Full Replay

Replaying thousands of events on every command is wasteful. Snapshots solve this by periodically serializing the full aggregate state — say, every 100 events — and storing it alongside the event stream. On load, you deserialize the snapshot and replay only the events that came after it:

type Snapshot struct {
    AggregateID string
    Version     int
    State       []byte // serialized BankAccount
}

// LoadWithSnapshot replays only events after the last snapshot.
func LoadWithSnapshot(id string, store EventStore, snap *Snapshot) (*BankAccount, error) {
    account := &BankAccount{}
    fromVersion := 0

    if snap != nil {
        if err := json.Unmarshal(snap.State, account); err != nil {
            return nil, err
        }
        fromVersion = snap.Version
    }

    // Replay only events after the snapshot version
    events, err := store.LoadFrom(id, fromVersion)
    if err != nil {
        return nil, err
    }
    for _, event := range events {
        if err := account.Apply(event); err != nil {
            return nil, err
        }
    }
    return account, nil
}

A common strategy is to take a snapshot whenever the version crosses a multiple of N (e.g., every 100 events). Snapshots are disposable — you can always delete them and rebuild from scratch by replaying the full stream.

Projections: Read Models Built From Events

Event-sourced systems pair naturally with CQRS — separating the write side (commands → events) from the read side (events → query-optimized views). A projection subscribes to the event stream and builds whatever read model best serves your queries. You might project balances into Redis for sub-millisecond lookups, into a reporting database for analytics, or into a search index for full-text queries — all from the same event stream:

// BalanceProjection builds a fast lookup table from events.
type BalanceProjection struct {
    balances map[string]int64
}

func NewBalanceProjection() *BalanceProjection {
    return &BalanceProjection{balances: make(map[string]int64)}
}

func (p *BalanceProjection) Handle(event Event) error {
    var d struct{ Amount int64 `json:"amount"` }
    switch event.Type {
    case "MoneyDeposited":
        json.Unmarshal(event.Data, &d)
        p.balances[event.AggregateID] += d.Amount
    case "MoneyWithdrawn":
        json.Unmarshal(event.Data, &d)
        p.balances[event.AggregateID] -= d.Amount
    }
    return nil
}

func (p *BalanceProjection) Balance(id string) int64 {
    return p.balances[id]
}

The projection never modifies the event store — it only reads events and updates its own state. If you need a new view (say, "transactions by date"), you spin up a new projection, replay all historical events, and you have a fully populated read model without touching the write side.

When to Use Event Sourcing (and When Not To)

Event sourcing shines when audit trails matter, when you need temporal queries ("what was the state at time T?"), or when you want to feed multiple downstream systems from a single source of truth. Financial systems, inventory management, and collaborative editing are natural fits.

But it comes with real costs that you should weigh carefully:

  • Schema evolution: events are immutable, but your understanding of them evolves. Version your events (AccountOpened_V1, AccountOpened_V2) or use upcasters — functions that transform old event formats to new ones during replay.
  • Eventual consistency: projections lag behind the write side. If a user deposits money and immediately checks their balance via a projection, the projection might not reflect the deposit yet. Design your UI for this.
  • Complexity: event sourcing adds conceptual weight. A CRUD screen for managing user profiles doesn't need an event stream — the audit trail and temporal queries would never be used. Reserve event sourcing for aggregates where the history genuinely matters.

Wrapping Up

Event sourcing trades the simplicity of mutable state for something more powerful: a system where every decision is recorded, replayable, and queryable in any shape you need. The core building blocks — append-only event stores, aggregate reconstruction via Apply, optimistic concurrency, snapshots, and projections — compose cleanly and map naturally to Go's interface-driven design.

Start with the event store and aggregate. Add snapshots when performance demands it. Layer projections on top when your read patterns diverge from your write model. And remember: the event store is permanent infrastructure. Choose your event schemas carefully, because once written, they're part of your system's history forever.

Leave a Reply

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