Module 6 · Lesson 2 · ~20 min read
Canton's transaction stream is an event log. Most non-trivial Canton integrations consume it and project it into local state — a SQL view, a search index, a cache. The pattern is event sourcing, and the tool you build on top of it is a state machine.
You have:
Created(c1), Created(c2), Archived(c1), etc.The discipline: state is always rebuildable by replaying events from the beginning. You never modify state except by applying an event.
type Event interface {
Apply(s *State) *State
}
type CreatedEvent struct { ID, Owner string; Amount int64 }
func (e CreatedEvent) Apply(s *State) *State {
new := s.copy()
new.Contracts[e.ID] = Contract{Owner: e.Owner, Amount: e.Amount}
return new
}
type ArchivedEvent struct { ID string }
func (e ArchivedEvent) Apply(s *State) *State {
new := s.copy()
delete(new.Contracts, e.ID)
return new
}
// Project a stream into final state:
func Project(events []Event) *State {
s := EmptyState()
for _, e := range events {
s = e.Apply(s)
}
return s
}
This is event sourcing in 30 lines. Canton's Update messages from the transaction stream are your Event values; your projection function transforms them into whatever local shape you need.
The "copy on every apply" pattern above is functional and pure. In production code that processes high-throughput streams, you typically mutate state in place — you just need to be careful that you never need to "undo" a mutation:
func (e CreatedEvent) Apply(s *State) {
s.Contracts[e.ID] = ... // in-place; faster
}
The strict invariant: events are committed only after the apply succeeds and the offset is advanced atomically. If the process dies mid-apply, on restart you replay from the last persisted offset.
You read events from offset N and apply them. At some point you need to persist: "we've processed up to offset M; restart should resume from M+1." Patterns:
| Pattern | When |
|---|---|
| Persist offset alongside state in the same transaction | State lives in a SQL DB. Use one transaction to update state + offset table. |
| Persist offset less often than events | Replay is cheap and idempotent. Trade some replay-on-startup for higher steady-state throughput. |
| Persist offset per event | Replay is expensive; you want to minimize re-work after a crash. |
Because crashes happen, every projection function should be idempotent — applying the same event twice produces the same state as applying it once.
// NOT idempotent — double-applying double-counts
s.Total += e.Amount
// Idempotent — set, not increment
s.PerContractTotal[e.ID] = e.Amount
s.RecomputeTotal() // or maintain via the per-contract map
If your projection is naturally idempotent, replays are free. If not, you need additional bookkeeping (per-event "applied" markers, dedup tables) to get the same property. Lesson 3 dives into idempotency more broadly.
A state machine is a fixed set of states + a transition function: (state, event) → state. Useful for modeling things with a well-defined lifecycle.
Canton command lifecycle (simplified):
submit
┌──────────▶ Submitted
│ │
│ │ accept
│ ▼
Initial Accepted
│ │
│ │ commit
│ ▼
│ Committed (terminal)
│
│ reject
▼
Rejected (terminal)
In Go:
type CommandState int
const (
StateInitial CommandState = iota
StateSubmitted
StateAccepted
StateCommitted
StateRejected
)
func (s CommandState) Next(ev CommandEvent) (CommandState, error) {
switch {
case s == StateInitial && ev == EventSubmit:
return StateSubmitted, nil
case s == StateSubmitted && ev == EventAccept:
return StateAccepted, nil
case s == StateAccepted && ev == EventCommit:
return StateCommitted, nil
case s == StateInitial && ev == EventReject:
return StateRejected, nil
default:
return s, fmt.Errorf("invalid transition: %v from state %v", ev, s)
}
}
This pattern catches bugs — a command can't go from Committed back to Submitted. The transition function is the single source of truth for what's allowed.
If your event stream is millions of events long, replay-from-zero is slow. The fix is snapshotting: periodically dump current state, persist it, and on restart load the snapshot first then replay only events since the snapshot's offset.
Snapshots are an optimization. Don't add them until replay time becomes painful.
Two anti-patterns to avoid in event-sourced systems:
Mutate state outside the event handler. If you "fix something" by writing to the projection's database directly, you've broken the invariant that state == replay(events). Next replay will undo your fix.
Make projections that depend on wall-clock time. Replay days later will produce different state. Time should come from the events themselves (event timestamps), not time.Now().
The capstone (Module 7) builds a transaction-stream consumer in Go. Even at its simplest, it follows this pattern: stream events, apply to a local representation, persist offset. Deriving from there, you might build:
All of them are event-sourcing variants on the same shape.