The Greenbox Story · Drawing the Lines

Domain-Driven Design: CQRS and Read Models in Go

June 28, 2026 · 11 min read

Maya wants a screen. Not one account: all of them. Who’s behind on payment, what we billed this week, which charges bounced. Ravi opens the ledger code and realises the question has no good answer in it.

The event-sourced ledger made one account perfectly explainable. Load its history, fold the events, and every charge and credit is there with its reason and timestamp. It was the right model for the question “why is this balance what it is.”

It is the wrong model for Maya’s question. “Show me every account in arrears” means loading every account in the system and replaying its entire history just to find out which ones owe money. For three thousand subscribers, each with a history of weekly charges, that’s hundreds of thousands of events folded to render one table, recomputed every time someone opens the page, and the number only grows. The write model was built to enforce rules on a single aggregate. Asking it to answer a question that ranges across all of them is asking it to be something it isn’t.

Charlotte draws two boxes on the whiteboard, with an arrow between them. “You’ve been treating reading and writing as one model. For the ledger, they want to be two.”

Two models, not one

The technique has a name, and in Event Storming it has a sticky note colour. In the Event Storming a Process playbook, the pale-green stickies are read models: the data a policy or a person consults before deciding what to do. “Before reserving stock, check current stock level.” Those green notes are about to become code.

CQRS stands for Command Query Responsibility Segregation, which is a heavy name for a light idea: the model you change the system through doesn’t have to be the model you read the system through. Commands go to the write model, the event-sourced Account, which exists to validate and record. Queries go to one or more read models, purpose-built tables shaped exactly like the questions people ask, that exist only to be read fast.

Two things are worth saying plainly before anyone over-reaches, because both are easy to get wrong.

CQRS does not require event sourcing, and event sourcing does not require CQRS. They’re separate ideas. You can split reads from writes over a plain SQL table, and plenty of systems should. But they compose well, and Billing is the case where they do: the team already has a durable, ordered stream of every money movement, which turns out to be the perfect feed for building any read model you like.

And CQRS is not free, so it’s not a default. Two models is two models: you maintain both, and they don’t agree instantly. You reach for it when the read and write shapes genuinely diverge, the way the ledger’s do. You don’t reach for it when one shape serves both, which is most of the time. Subscription, again, is the counter-example: a subscription is written and read in the same shape, so it has one model and a plain repository, and bolting CQRS onto it would be two things to keep in sync for no gain.

Projections: a read model built from events

A read model is fed by a projection: a function that folds events into a query-shaped row. If that sounds familiar, it should. It’s the same fold as the aggregate’s apply, pointed at a different target. The aggregate folds events into a balance it holds in memory. A projection folds the same events into rows it writes to a table built for reading.

// file: billing/readstore.go
package billing

import "context"

// ReadStore is the query-side database: denormalised tables shaped like the
// screens that read them. It knows nothing about aggregates or invariants.
type ReadStore interface {
	UpsertArrears(ctx context.Context, row ArrearsRow) error
	AccountsInArrears(ctx context.Context) ([]ArrearsRow, error)
}

type ArrearsRow struct {
	AccountID   AccountID
	Subscriber  string
	Balance     Money
	LastCharged time.Time
	DaysOverdue int
}

The projection listens for the ledger’s events and keeps the row up to date. It carries no business rules; its whole job is to maintain a fast answer to one question.

// file: billing/projection_arrears.go
type ArrearsProjection struct {
	read ReadStore
	subs SubscriberNames // read-only lookup for the display name
}

func (p *ArrearsProjection) On(ctx context.Context, e Event) error {
	switch e := e.(type) {
	case SubscriberCharged:
		return p.adjust(ctx, e.AccountID, e.Amount, e.DeliveryDay)
	case CreditIssued:
		return p.adjust(ctx, e.AccountID, e.Amount.Negate(), time.Time{})
	default:
		return nil // this projection only cares about money moving
	}
}

(p.adjust is elided here: it upserts the account’s row, moving the balance by the given amount and recomputing DaysOverdue from the last charge date.)

This is the same On(event) shape as the billing handlers that react to cross-context events. The difference is intent: those handlers reacted to events to do new work (create an invoice). A projection reacts to events to maintain a view. Same plumbing, different purpose.

Two ways to feed a projection

There are two ways the events reach the projection, and event sourcing the ledger is what makes the second one possible.

The first is the live stream. The projection subscribes to the publisher the team already built, and updates the read model as events flow past in real time. This is how the dashboard stays current minute to minute.

The second is replay. Because every billing event is durable in the event store, you can rebuild any read model from scratch by reading the whole history and running it through the projection:

// file: billing/rebuild.go
func RebuildArrears(ctx context.Context, store EventStore, proj *ArrearsProjection) error {
	events, err := store.LoadAll(ctx) // every billing event, in order
	if err != nil {
		return err
	}
	for _, e := range events {
		if err := proj.On(ctx, e); err != nil {
			return err
		}
	}
	return nil
}

One honest note: LoadAll wasn’t in the EventStore interface when the ledger was built. The interface grew a third method the first time a projection needed to range over the whole stream. That’s how interfaces should grow, when a consumer turns up with a real need, not speculatively.

This is the quiet superpower of having event-sourced the write side. A new dashboard Maya thought of this morning is not a data migration and not a backfill script that guesses at history. It’s a new projection, replayed over events you already kept, populated in one pass. When a projection’s shape changes, you don’t migrate it in place; you drop it and rebuild. The events are the source of truth, and every read model is a disposable opinion about them.

Maya’s dashboard

With the projection maintaining the table, Maya’s question stops being a replay of millions of events and becomes a single indexed read:

// file: billing/queries.go
type ArrearsQuery struct {
	read ReadStore
}

func (q *ArrearsQuery) Run(ctx context.Context) ([]ArrearsRow, error) {
	return q.read.AccountsInArrears(ctx)
}

No aggregates loaded, no events folded at read time, no business logic on the query side at all. The work happened once, when each event was projected. The dashboard just reads the answer. The same event store can feed a weekly-revenue projection, a failed-charges worklist, and a subscriber-facing statement, each its own table, each shaped like its own screen, all fed from the one stream of truth.

The cost: eventual consistency

The bill for splitting the models comes due as a lag, and it’s the thing to understand before you adopt this.

The read model trails the write model by however long the projection takes to catch up. Charge a subscriber and the Account in the write model knows immediately; the arrears dashboard knows a moment later, once the event has been projected. That window is usually milliseconds, but it is never zero, and you have to design around it being non-zero.

The rule that keeps this sane: read from the model that fits the consistency you need. When you’ve just charged an account and you need to make a decision based on its new balance right now, ask the write-side aggregate, which is always current. When you’re rendering a dashboard, a report, a list, somewhere a sub-second lag is invisible, read the projection. Trouble comes from reading a projection where you needed certainty: showing a “paid” confirmation off a view that hasn’t caught up yet, and confusing the subscriber. Match the read to the guarantee.

This is the central trade-off of CQRS, and it’s why it isn’t a default. You’re buying fast, purpose-built, independently-scalable reads, and the price is that the reads are a beat behind the writes. Where that beat is invisible, the trade is excellent. Where it isn’t, don’t make it.

What this bought, and what it cost

The benefits stack up where reads and writes diverge:

  • Reads are fast and purpose-built, because each read model is shaped like exactly one question.
  • One truth, many views. The arrears dashboard, the revenue report, and the subscriber statement are different projections of the same event stream, never out of step with each other because they fold the same events.
  • New views are cheap and retroactive, rebuilt from history you already kept.
  • Reads scale separately from writes, on their own store, without touching the model that guards the money.

And the costs are equally real:

  • Eventual consistency, the lag above, which you design around rather than wish away.
  • More moving parts: projections to write, a read store to run, rebuild jobs to operate.
  • Two models to hold in your head, which is two models’ worth of cognitive load. Only worth it where the read and write shapes actually pull apart.

The whole picture

Stand back from the whole thing and the shape of the decision is the real lesson. Two contexts, two completely different persistence strategies, each earned:

  • Subscription is state-stored. Its questions are about what is, it’s written and read in one shape, and its currentPause value object models the present cleanly. No events as truth, no read models, no split. The simplest thing that works, because the simplest thing is correct here.
  • The Billing ledger is event-sourced with CQRS read models. Its questions are about what happened and they range across every account, so the history is the source of truth and the views are projections of it. The most machinery in the system, because this is the one place the machinery pays.

The team wrote it up as an ADR, the way Charlotte had taught them to: event-source the Billing ledger, add read models for the cross-cutting queries, keep Subscription state-stored. Context, decision, consequences. The next person who notices that Billing looks nothing like Subscription, and wonders whether someone over-engineered it, finds the reasoning in the record instead of having to reverse-engineer it.

That’s the discipline these techniques are really teaching. Not “use event sourcing” or “use CQRS”, but: each bounded context gets the persistence its questions deserve, you can say why in a sentence, and you don’t pay for complexity a context never asked for. Subscription stays boring on purpose. The ledger earns its keep.

What comes next

The substitution engine got built, and it works, but it’s complicated enough that nobody can explain it to a new joiner and the diagram still shows it as a single box. The team has been carefully not drawing a particular level of detail. It’s time: when a container earns its own zoom.

The next chapter, When a Container Earns Its Own Zoom, publishes around 30 June.

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