The Greenbox Story · Drawing the Lines

Domain-Driven Design: Event Sourcing the Ledger in Go

June 27, 2026 · 17 min read

Ravi is staring at a subscriber’s billing history, trying to explain a $4.20 discrepancy. The database knows the balance. It has no idea how the balance got there.

By the time the team finished translating between contexts at the boundary, Charlotte had said something that stuck: “None of this required event sourcing infrastructure. Just Go packages, interfaces, and the discipline to translate at the boundary.” She was right. Modelling the domain, publishing events across boundaries, translating at the edges, none of it needed the events to be the source of truth. They were messages. They did their job and were cleared.

Then a subscriber emails support. She paused for a week in June, and she’s sure she was charged anyway. Ravi opens the Billing code. The Account type stores a balance and a lastChargedAt. He can see what she owes today. He cannot see the sequence of charges and credits that produced it, because of the way the team had wired events across the boundaries: load, act, save, publish, clear. Every SubscriberCharged event was published to whoever cared and then wiped from the aggregate. The history went out the door and was never kept.

Charlotte looks at the screen. “This is the one place where that pattern is wrong. Billing isn’t a thing that is a balance. It’s a thing that remembers a sequence of money movements. The events aren’t a side effect here. They’re the data.”

When “what happened” is the question

Most aggregates answer questions about the present. Is this subscription paused? What box size? Which delivery day? The Subscription entity stores current state because current state is what every caller asks for. Nobody loads a subscription to audit the full history of its box-size changes; they load it to find out what to pack this week.

Billing is the opposite. The questions are almost all about the past. Why is this balance what it is? Was she charged during the pause? What did she owe on the delivery day she’s disputing? What’s our revenue for the week, and which charges failed? Every one of those is a question about a sequence of events over time, not a snapshot. And a snapshot can’t answer them. By the time you’ve collapsed a history into a single balance field, you’ve thrown away the only thing that could.

This is the situation event sourcing is for. Event sourcing means the aggregate’s state is not stored directly; instead, you store the ordered log of events that happened, and you compute current state by replaying them. The log is the system of record. The balance is just a number you derive from it, the way a bank statement’s closing figure is derived from the lines above it, not the other way around.

The one context that earns it

Charlotte is careful to draw the line before anyone gets excited. “We are event sourcing the ledger. We are not event sourcing the subscription. We are not event sourcing the world.”

It’s worth being precise about why, because the temptation after a technique lands is to apply it everywhere.

Event-source a context when the history is the product: money, ledgers, anything you might have to defend to a subscriber, an accountant, or a regulator; anything where “how did we get here” is a question people will actually ask; anything where you want to answer questions you haven’t thought of yet, because you kept the raw material.

Keep a context state-stored when the present is all anyone queries. Subscription is the clean example. Its questions are about what is. Its currentPause is a value object that says “paused, and here’s why and since when”, and nil when it’s active. That’s exactly right for Subscription, and event sourcing it would be ceremony with no payoff: more machinery, slower reads, a versioning burden, all to answer questions nobody asks of it.

The cost of event sourcing is real, and it’s worth knowing before you opt in: the events are immutable forever, so changing their shape becomes a migration problem that never goes away; you can’t query across aggregates without building something extra; and every load is a replay. You take that on where the benefit is worth it, and the benefit is only worth it where the history matters. Event sourcing is a per-context decision, not a house style.

The ledger clears that bar. So the ledger gets it, and nothing else does.

The events are the state

Start with the events themselves. These look like the domain events already crossing the boundaries, with one difference in status: they aren’t notifications about a state change, they are the state change. There’s nothing else.

// file: billing/events.go
package billing

import "time"

type Event interface{ isBillingEvent() }

// SubscriberCharged is recorded when a box is delivered. The amount and reason
// come from Supply Matching once the week's actual contents and substitutions
// are known, which is why Billing charges on delivery day, not at signup.
type SubscriberCharged struct {
	AccountID   AccountID
	Amount      Money
	DeliveryDay time.Time
	Reason      string
	OccurredAt  time.Time
}

// CreditIssued puts money back: a pause that landed after the box was costed,
// a substitution that came in cheaper than promised, a goodwill gesture.
type CreditIssued struct {
	AccountID  AccountID
	Amount     Money
	Reason     string
	OccurredAt time.Time
}

// ChargeFailed records a payment attempt that didn't go through. It moves no
// money, but it belongs in the history so a gap in the charges has an answer.
type ChargeFailed struct {
	AccountID  AccountID
	Amount     Money
	Reason     string
	OccurredAt time.Time
}

func (SubscriberCharged) isBillingEvent() {}
func (CreditIssued) isBillingEvent()      {}
func (ChargeFailed) isBillingEvent()      {}

Now the aggregate. The line that matters is the comment on balance: it is not authoritative. It’s a cache of the events, recomputed every time the account is loaded.

// file: billing/account.go
package billing

type Account struct {
	id      AccountID
	version int     // how many events have been applied
	balance Money   // derived from the events, never the source of truth
	changes []Event // events raised in this unit of work, not yet stored
}

Commands raise events; only events change state

Over in the Subscription context, each method does two things: it sets its own fields (s.status = StatusPaused) and records an event. Here, those two steps collapse into one, and the order of authority flips. A command never assigns to a field. It raises an event, and applying the event is what moves the balance.

// file: billing/account.go
import "fmt"

// Charge records that a subscriber was billed for a delivery.
func (a *Account) Charge(amount Money, day time.Time, reason string) error {
	if amount.IsZero() {
		return fmt.Errorf("nothing to charge")
	}
	a.raise(SubscriberCharged{
		AccountID:   a.id,
		Amount:      amount,
		DeliveryDay: day,
		Reason:      reason,
		OccurredAt:  time.Now(),
	})
	return nil
}

// Credit puts money back onto the account.
func (a *Account) Credit(amount Money, reason string) error {
	if amount.IsZero() {
		return fmt.Errorf("nothing to credit")
	}
	a.raise(CreditIssued{
		AccountID:  a.id,
		Amount:     amount,
		Reason:     reason,
		OccurredAt: time.Now(),
	})
	return nil
}

The work happens in two small methods that every command funnels through:

// file: billing/account.go

// raise is how every command changes the aggregate: never by assignment,
// always by recording an event and then applying it.
func (a *Account) raise(e Event) {
	a.apply(e)
	a.changes = append(a.changes, e)
}

// apply is the only code that mutates an Account's fields. It runs for brand
// new events (via raise) and for historical events (during reconstitution),
// so the balance is computed identically whether the charge happened a second
// ago or a year ago.
func (a *Account) apply(e Event) {
	switch e := e.(type) {
	case SubscriberCharged:
		a.balance = a.balance.Add(e.Amount)
	case CreditIssued:
		a.balance = a.balance.Sub(e.Amount)
	case ChargeFailed:
		// no money moved; the event exists so the gap in charges has a reason
	}
	a.version++
}

That single rule, only apply writes to the fields, is what makes the history trustworthy. There is no way to change the balance that doesn’t leave a record, because changing the balance and recording an event are now the same act. When Ravi’s subscriber asks why her balance is what it is, the honest answer isn’t reconstructed after the fact. It was the cause of the balance all along.

Reconstituting from history

Loading an account means replaying its events. The same apply runs, but through a different door, because these events already happened and must not be recorded as new changes.

// file: billing/account.go

// NewAccountFromHistory rebuilds an account by replaying its events in order.
func NewAccountFromHistory(id AccountID, history []Event) *Account {
	a := &Account{id: id}
	for _, e := range history {
		a.apply(e) // apply, not raise: these are already stored
	}
	return a
}

apply, not raise. Get that wrong and you’d re-append the entire history to changes on every load and try to store it all again. The distinction is the whole trick: raise is for new facts, apply is for moving state, and reconstitution is pure apply.

The event store

The events need somewhere durable to live. The interface is small, and it carries the one piece of safety that matters when two requests touch the same account at once.

// file: billing/eventstore.go
package billing

import (
	"context"
	"errors"
)

type EventStore interface {
	// Load returns every event for an account, in the order they happened.
	Load(ctx context.Context, id AccountID) ([]Event, error)

	// Append adds new events. expectedVersion is the version the caller read
	// before acting; if the stored stream has moved past it, someone else
	// wrote first and Append fails rather than interleaving two histories.
	Append(ctx context.Context, id AccountID, expectedVersion int, events []Event) error
}

var ErrConcurrentChange = errors.New("billing: account changed since it was loaded")

The expectedVersion check is the guard rail. Two support agents issuing a credit on the same account at the same moment both load version 12, both decide to append. The first append succeeds and the stream moves to version 13. The second arrives still believing the stream is at 12, the store sees the mismatch, and it returns ErrConcurrentChange instead of quietly stitching the two together. The caller reloads, re-checks the invariant against the now-current balance, and tries again. No money is moved on a stale view of the account.

The repository: load, act, append

The repository turns the store into something the rest of Billing can use. It mirrors the load-act-save shape the rest of the system already uses, with Save now meaning “append the new events”.

// file: billing/repository.go
type AccountRepository struct {
	store EventStore
}

func (r *AccountRepository) Load(ctx context.Context, id AccountID) (*Account, error) {
	history, err := r.store.Load(ctx, id)
	if err != nil {
		return nil, err
	}
	return NewAccountFromHistory(id, history), nil
}

func (r *AccountRepository) Save(ctx context.Context, a *Account) error {
	if len(a.changes) == 0 {
		return nil // nothing happened; nothing to store
	}
	// The version we expect on disk is where we were before this unit of
	// work's events were applied.
	expected := a.version - len(a.changes)
	if err := r.store.Append(ctx, a.id, expected, a.changes); err != nil {
		return err
	}
	a.changes = nil
	return nil
}

One thing worth noticing: the events the store keeps are the same events Billing already publishes to other contexts. A SubscriberCharged is the ledger’s system of record and the message Fulfilment or the subscriber’s email receipt reacts to. The application service appends them to the store and hands the same slice to the publisher before clearing it. The durable history and the integration message are the same struct, and you write it once.

Snapshots, when replay gets slow

Replaying a few dozen events is free. Replaying years of weekly charges on every page load is not. The standard answer is a snapshot: every N events, store the folded state and the version it represents, and on load start from the snapshot and replay only the tail.

// file: billing/snapshot.go
type Snapshot struct {
	Version int
	Balance Money
}

func NewAccountFromSnapshot(id AccountID, snap Snapshot, tail []Event) *Account {
	a := &Account{id: id, version: snap.Version, balance: snap.Balance}
	for _, e := range tail {
		a.apply(e)
	}
	return a
}

A snapshot is an optimisation, not a source of truth. Delete every snapshot and the system is unchanged, just slower, because the events are still there to rebuild from. That asymmetry, snapshots disposable and events sacred, is a good test of whether you’ve kept the design honest.

What this bought

Ravi reopens the dispute with the new code. He loads the account and prints its history. The pause week is right there: a CreditIssued for the box that was cancelled, dated the Tuesday the pause took effect, reason "paused for week of 8 June", sitting next to the SubscriberCharged that confused her. The $4.20 was a part-week proration, recorded with its reason at the moment it happened. The dispute answers itself in the data, and Ravi replies in five minutes instead of an afternoon of guessing.

That’s the shape of the payoff:

  • Disputes and audits resolve from the record, because the record is complete by construction.
  • Temporal questions are just partial replays. “What did she owe on the disputed delivery day?” is the balance you get by folding events up to that date, no extra storage required.
  • New questions get answered retroactively. When Maya asks something nobody designed for, you usually already have the events to answer it. You kept the raw material.

What it cost

None of this is free, and the costs are exactly the ones Charlotte named before opting in.

Events are immutable forever. The day you want to add a field to SubscriberCharged, every event already in the store predates it. You can’t rewrite history; you handle it, usually by versioning the event and upcasting old ones as they’re read:

// file: billing/upcast.go
// v1 SubscriberCharged had no Reason field. When we read an old event without
// one, fill a sensible default rather than rewriting what's stored.
func upcastCharged(e SubscriberChargedV1) SubscriberCharged {
	return SubscriberCharged{
		AccountID:   e.AccountID,
		Amount:      e.Amount,
		DeliveryDay: e.DeliveryDay,
		Reason:      "(reason not recorded)",
		OccurredAt:  e.OccurredAt,
	}
}

That upcaster is now a permanent fixture. It never gets deleted, because the v1 events never go away. Multiply by every schema change over the system’s life and you have a real, ongoing tax that a state-stored table simply doesn’t pay.

You can’t query across accounts. The write model answers questions about one account beautifully and questions about all accounts not at all. “Show me every account in arrears this week” can’t be served by loading and replaying every account in the system. That query needs a different model entirely, built for reading across the whole set.

There’s more machinery. A store, a replay path, snapshots, concurrency checks, event versioning. It’s more code and more concepts than a row in a table, and it only pays for itself where the history genuinely matters. Which is why it stops at the ledger. Subscription keeps its currentPause value object and its plain repository, and the team wrote the choice down as an ADR so the next person who wonders why Billing looks so different from Subscription gets the answer from the record instead of guessing.

What comes next

The ledger can now explain any single account perfectly. But Maya doesn’t want one account, she wants the dashboard: every subscriber in arrears, this week’s revenue, the failed charges that need chasing. You cannot replay every account in the system to render a table, and the write model was never built to try. That’s the read side, and it’s a model of its own: CQRS and read models in Go.

The next chapter, Domain-Driven Design: CQRS and Read Models in Go, publishes around 28 June.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.