All posts
GoArchitecturePatterns

Event-Driven Go in Practice: CQRS and Event Sourcing — When They Help and When They Hurt

April 17, 202612 min readBy Yogendra Singh

CQRS and event sourcing appear together so often in architecture talks that engineers sometimes treat them as a single pattern. They're not — they solve different problems, can be adopted independently, and have different cost profiles. Having used both in a high-throughput Go fraud-detection system processing 48K events per second, I have opinions about when they earn their complexity and when they're the wrong tool for the situation.

This isn't a theoretical overview. It's a field report from systems where these patterns were chosen deliberately, where they paid off, and where the implementation revealed costs that the enthusiastic blog posts don't mention. The goal is to help you make an informed decision, not to evangelize a pattern.

CQRS: separating reads from writes

Advertisement

Command Query Responsibility Segregation separates the write path (commands that change state) from the read path (queries that return data). In a traditional CRUD system, both go through the same model and the same database. In a CQRS system, they're separate: the write side accepts commands, validates them, applies business logic, and persists state changes; the read side maintains one or more query-optimized projections of that state, updated asynchronously.

The concrete benefit is that you can optimize reads and writes independently. In our fraud system, the write side needed to be fast and consistent — a fraud decision had to be made and persisted in under 12ms. The read side needed to support analytics queries across millions of historical decisions, often with complex aggregations. Those are fundamentally different workload shapes. CQRS let us use PostgreSQL for the write side (transactional, consistent, normalized) and a read-optimized materialized view updated by a background process for the analytics side — without forcing a compromise that would have degraded both.

Event sourcing: state as a sequence of events

Event sourcing replaces the conventional 'store current state' model with 'store every event that led to current state.' Instead of a `fraud_decisions` table with one row per account containing the latest status, you have an `events` table with one row per decision event. To find the current state of an account, you replay its events in order. Current state is a derived view, not the source of truth.

This pattern has a specific, genuine superpower: because the event log is the source of truth, you can build new read models retroactively. If the business asks 'what would our fraud decisions have looked like with the new rule set over the past 6 months,' you can replay the event stream through the new rules without any data migration. We used this capability to evaluate every significant model change before deploying it — run the candidate model against 90 days of historical events in a staging environment and measure the delta. That kind of safe, offline evaluation is very hard to implement without an immutable event log.

Implementation in Go: what it actually looks like

In Go, an event sourcing store is often implemented as a struct with an `Append(event Event) error` method that writes to an append-only table, and a `Load(aggregateID string) ([]Event, error)` method that replays events for a given aggregate. The aggregate itself is a struct with an `Apply(event Event)` method that updates its internal state. The command handler loads the aggregate by replaying events, executes the business logic, and appends the resulting new event. This is mechanical to write and straightforward to test because each component is a pure function with no hidden state.

  • Use ULIDs (not UUIDs) for event IDs — lexicographic ordering means Postgres B-tree indexes stay hot for recent events.
  • Version your event schema with a `schema_version` field and handle migrations in the event deserializer, not the database.
  • Snapshotting: for aggregates with long event histories (thousands of events), cache a snapshot of the aggregate state periodically so replay doesn't have to start from event zero on every command.
  • Projection workers are separate goroutines that subscribe to new events and update read models — keep them idempotent so they can safely replay on failure.
  • Use PostgreSQL's LISTEN/NOTIFY to wake projection workers immediately when new events are appended, rather than polling on a timer.

When CQRS and event sourcing genuinely help

These patterns earn their complexity in systems with three characteristics: high audit requirements (financial, legal, compliance), asymmetric read/write load, and the need to query historical state at a point in time. Fraud detection checks all three boxes. So does general ledger accounting, inventory management with dispute resolution, and any system where the business needs to answer 'what did you know and when did you know it.'

They also help when you need to integrate multiple downstream systems from a single source of business events — the event stream becomes the integration bus. New consumers subscribe to the stream and build their own projections without requiring changes to the write side. This is genuinely useful and is one of the cleaner integration patterns available in a microservices architecture.

When they hurt: the real costs

Event sourcing increases cognitive load for every developer who touches the system. The mental model of 'current state is a function of the event history' is not intuitive to engineers who haven't worked with it, and onboarding takes real time. Simple mutations that are a single SQL UPDATE in a CRUD system become a command, an event, and an async projection update — three moving parts where one existed. Debugging is harder because the state you see in the read model may be slightly behind the event log, and tracing a specific command through the system requires understanding the full flow.

Event schema evolution is the specific pain point that most blog posts underplay. When your events are the source of truth and you have two years of them in production, changing an event's schema is a careful operation. You cannot just alter a column. You need to handle multiple schema versions in your deserializer, which means your codebase carries that complexity forever. This is manageable with discipline, but 'manageable with discipline' is a euphemism for 'will occasionally produce bugs during refactors.'

The question is not whether CQRS and event sourcing are good patterns — they are. The question is whether your system's specific requirements justify the implementation and operational cost. For fraud detection, ledger systems, and compliance-heavy domains, the answer is usually yes. For a standard content management system or user profile service, it's almost certainly no.

The decision framework I use

Before recommending event sourcing to a team, I ask four questions: Do you have a compliance requirement for a complete, immutable audit trail? Is your read load shaped differently from your write load in a way that would benefit from independent scaling? Does the business need to retroactively query state at arbitrary historical points? Will you add multiple downstream consumers of business events over the next year? If the answer to two or more is yes, event sourcing deserves serious consideration. If the answer to all four is no, a well-structured CRUD system with a good transaction log is almost certainly the better tradeoff.

Architecture decisions like this one — pattern selection with eyes open to both the benefits and the costs — are the kind of thinking I bring to contract and advisory engagements. If you're designing a Go microservices system and want a second opinion on whether event sourcing is the right fit for your specific constraints, that's a useful conversation to have before you're six months into an implementation.

Open to select projects

Building something with AI?

I take on select AI engineering projects end-to-end — from React frontend to LLM pipeline on AWS. Tell me what you're building.

Advertisement