Event Sourcing and CQRS: A Practical Guide for Developers

The accountant walks up to your desk. "This account shows 150 dollars. Why?" You open the database, you look at the balance column. It says 150. That's all it can say. The twenty operations that led to that number? Overwritten, one by one, on every UPDATE. You can't answer.

That's the problem Event Sourcing solves. And CQRS is the companion that makes it usable day to day. These two words sound scary, we associate them with Kafka, microservices and unicorn architectures. The reality is simpler, and more useful. This guide lays out the mental model, says when these patterns are worth it (and when they're not), then traces a concrete implementation path in Go with PostgreSQL.

Event Sourcing in one sentence

Instead of storing the current state of a piece of data, you store every event that led to that state. The current state is recomputed by replaying the events in order.

A bank account is no longer a balance = 150 row. It's a list: Credited(200), Debited(70), Credited(20). The balance is the result of a computation, not a stored value. You've lost nothing, because you never overwrite anything. You only ever append.

// CRUD: you store the result. The history is overwritten.
account := Account{Balance: 150}

// Event Sourcing: you store the facts. The balance is recomputed.
events := []Event{Credited{200}, Debited{70}, Credited{20}}
balance := replay(events) // 150, rebuilt from the start

An event is a past fact, immutable, named in the past tense: OrderPlaced, PaymentConfirmed, AccountDebited. Once written, it never changes. That single decision shapes everything else.

CQRS in one sentence

You separate write operations (the commands, which change state) from read operations (the queries, which read state). Two paths, two models.

CQRS and Event Sourcing are two independent patterns. You can do CQRS without Event Sourcing, and the other way around. But in production they almost always go together: the write side produces events, and the read side serves from views computed off those events. The write model protects the business rules, the read model is optimized to be read fast.

The three real wins

You don't adopt Event Sourcing to look modern. You adopt it for three concrete things, and if none of them speaks to you, move on.

  • A free, immutable audit log. Every change is a timestamped event you never overwrite. In fintech, in healthcare, anywhere a regulator can ask "who did what, when", this is the argument that justifies everything else.
  • Temporal queries. "What was the balance last Tuesday at 2pm?" becomes trivial: you replay the events up to that date. With a CRUD table, that question is impossible to answer.
  • Rebuilding. A corrupted projection, a bug in a read view? You throw it away and recompute it from the events. The source of truth is intact by construction.

When NOT to use Event Sourcing

This is the section tutorials forget. Event Sourcing has a real cost: more code, a different mental model, a team to train. For most applications, a plain old CRUD with an audit table is more than enough.

Avoid Event Sourcing if your domain is a simple form that saves rows, if history doesn't interest you, if nobody will ever ask you "and before?", or if the team is discovering the topic on a project already under pressure. The pattern shines on domains where the history IS the data: accounts, payments, inventory, bookings, complex business workflows. Elsewhere, it adds friction without paying off.

The complete mental model

Three building blocks are enough to understand everything. Once they're clear, the rest is just implementation.

The aggregate is the guardian of business consistency. It's the one that says "no" when an operation is invalid (an account doesn't go below zero, an already-shipped order can't be cancelled). In Event Sourcing, the aggregate has no directly persisted state: its state is the result of replaying all its events from the beginning.

The command is an intent ("debit this account by 70"). It goes through the aggregate, which validates, and which produces one or more events if it's allowed, or an error if it isn't. Never both.

The projection is a view computed from the events. "The balance of every account" is a projection. "The list of pending orders" is another. These are what your queries read, and you can throw them away and recompute them at will.

The implementation path, and where to dig deeper

I've written a series of articles that take each block in hand, with real, tested Go code. Here's the order that makes sense for learning, from the mental model to production.

  1. The starting point: understanding why writes must be serialized per aggregate while reads can be massively parallel, in concurrency vs parallelism in Go applied to Event Sourcing. It's also the article that defines the concepts with no prerequisites.
  2. The aggregate: the Transition() function that replays events, and the Clone() trap Go makes you forget (slices share their backing array and silently corrupt old states), in CQRS in Go: the aggregate, Transition() and Clone().
  3. The command handler: the Handle(ctx, state, cmd) (Events, error) signature that makes business logic testable without mocks or a database, in side-effect-free command handlers.
  4. Storage: why PostgreSQL is enough as an event store, with an append-only table and optimistic locking via UNIQUE(aggregate_id, version), in PostgreSQL as an event store.
  5. Multiple aggregates cooperating: event choreography over a central orchestrator, and sagas, in sagas and event choreography.
  6. Reliability against duplicates: the idempotency key for retries and double-clicks, then the four idempotency layers of a complete CQRS system, in the basics of idempotency then commands, projections and outbox.
  7. The advanced case: how to return a synchronous result to the HTTP client in an asynchronous system, and keep a consistent audit log, in the pubsub bridge and atomic audit.

The minimal stack that's enough

The biggest trap in Event Sourcing isn't the concept, it's over-engineering. You get sold Kafka, EventStoreDB, a distributed message bus and three brokers before you even have a single aggregate that works.

For 90% of projects, PostgreSQL alone does all the work. An append-only table for events, a UNIQUE constraint to handle concurrency, polling or LISTEN/NOTIFY for projections. Snapshots and an outbox to a broker only arrive when a real volume or integration problem demands them, not before. Start small. You'll add infrastructure the day the pain is real.

What to remember

Event Sourcing and CQRS aren't an architecture you adopt wholesale because it's fashionable. They're tools for a specific problem: when the history of your data matters as much as its current state. If a regulator, an accountant or a production bug might one day ask you "and before?", they're worth their cost.

The right first step isn't to re-architect everything. It's to take a single aggregate that matters (an account, an order, a wallet), model it as events on a PostgreSQL table, and see what changes. The rest follows, block by block.

Frequently asked questions

What's the difference between Event Sourcing and CQRS?

Event Sourcing describes how you store data: a sequence of immutable events rather than the current state. CQRS describes how you organize code: one path to write (commands), another to read (queries). They're two independent patterns, but in production you almost always combine them, because one feeds the other.

Do you need Kafka or EventStoreDB for Event Sourcing?

No. For the vast majority of projects, PostgreSQL is enough: an append-only table, a uniqueness constraint for concurrency, polling or LISTEN/NOTIFY for projections. Kafka and dedicated event stores answer specific volume or integration needs, not a prerequisite of the pattern.

Isn't Event Sourcing too complex for my project?

Often, yes. If your domain is a simple CRUD with no need for history, a classic audit table is enough and costs far less. Event Sourcing is worth its cost when the history is the data: accounts, payments, inventory, bookings, or any domain where "who did what, when" is a real question.

Where do you start concretely?

With a single aggregate that matters, modeled as events on a PostgreSQL table. Understand replay and the aggregate first, then the command handler, then storage, then idempotency. The linked article series follows exactly that order, with tested Go code.

Comments (0)