Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
Event sourcing is the natural completion of CQRS: instead of storing the current state of an entity, you store the sequence of commands that produced that state as immutable events, and derive all read projections by replaying those events. This gives you a complete audit log for free, the ability to time-travel to any past state, and a clean decoupling between writes (append to the event log) and reads (rebuild projections from events). The cost is that every read model must be rebuilt from scratch if you change the projection logic, and 'what is the current balance?' becomes 'replay all events and compute it' — which is why event stores are append-only and projections are pre-computed snapshots.
A minimal event store that appends domain events and replays them to rebuild a post projection from scratch.
Use these three in order. Each builds on the one before.
In one paragraph, explain event sourcing to someone who has only ever stored current state in a database.
Walk me through how a post projection is rebuilt from an event log step by step — what happens to the projection struct at each event type.
Given an event-sourced system with 5 years of events per aggregate, how would snapshots work and what tradeoffs do they introduce for replay correctness vs. performance?
package main
import (
"fmt"
"time"
)
// Events are immutable facts. No state, just what happened.
type Event struct {
Type string
AggID string
Payload map[string]any
OccuredAt time.Time
Seq int
}
type EventStore struct {
events []Event
seq int
}
func (es *EventStore) Append(aggID, eventType string, payload map[string]any) {
es.seq++
es.events = append(es.events, Event{
Type: eventType,
AggID: aggID,
Payload: payload,
OccuredAt: time.Now(),
Seq: es.seq,
})
}
func (es *EventStore) EventsFor(aggID string) []Event {
var out []Event
for _, e := range es.events {
if e.AggID == aggID {
out = append(out, e)
}
}
return out
}
// Projection — rebuilt by replaying events.
type PostProjection struct {
ID string
Title string
Published bool
Tags []string
}
func ReplayPostProjection(events []Event) PostProjection {
var p PostProjection
for _, e := range events {
switch e.Type {
case "PostCreated":
p.ID = e.AggID
p.Title = e.Payload["title"].(string)
case "TagAdded":
p.Tags = append(p.Tags, e.Payload["tag"].(string))
case "PostPublished":
p.Published = true
}
}
return p
}
func main() {
store := &EventStore{}
// Commands produce events — no mutable state storage.
store.Append("p1", "PostCreated", map[string]any{"title": "Hello Event Sourcing"})
store.Append("p1", "TagAdded", map[string]any{"tag": "go"})
store.Append("p1", "TagAdded", map[string]any{"tag": "arch"})
store.Append("p1", "PostPublished", nil)
// Replay to get current state.
events := store.EventsFor("p1")
proj := ReplayPostProjection(events)
fmt.Printf("Post: %+v\n", proj)
fmt.Printf("Events stored: %d — full audit log\n", len(events))
}go run main.go