Your Aggregate is Not a Table¶
When developers first encounter Event Sourcing, they bring their mental models with them. Years of working with objects and tables have shaped how they think about data. And so, when they hear the word "Aggregate," something familiar clicks in their brain: an Aggregate must be like an object, and objects map to tables. This intuition feels right. It's also wrong, and it leads to Event Sourcing that looks suspiciously like CRUD with extra steps.
I've seen this pattern countless times. Teams build what they call an "event-sourced system" and end up with a single books table containing every field their Book Aggregate has. They've essentially rebuilt a relational database, just with events as the transport mechanism. The power of Event Sourcing, the flexibility it promises, remains untapped.
The Aggregate Misconception¶
Here's what developers typically think an Aggregate is: a container for all the data about a thing. They imagine a Book Aggregate and start listing properties:
BookAggregate {
id: string
title: string
author: string
isbn: string
currentBorrower: string | null
dueDate: Date | null
location: string
condition: string
purchasePrice: number
acquisitionDate: Date
lastInspectionDate: Date
popularityScore: number
}
This looks like an object. It has all the fields. It maps neatly to a database table. And therein lies the mistake: treating the Aggregate as a data container.
When you think this way, your Aggregate becomes a bloated representation of everything you might ever want to know about a book. It mirrors the structure of your Read Model because you haven't yet realized that these are fundamentally different concepts serving fundamentally different purposes.
What an Aggregate Actually Is¶
An Aggregate is a consistency boundary for making decisions. That's it. Its purpose is to ensure that business rules are enforced when commands are processed. It needs only the information required to decide whether a command is valid.
Consider the BorrowBook command. To decide if a book can be borrowed, what do you actually need to know? Just one thing: is the book currently available? You don't need the title, the author, the ISBN, the purchase price, the location, or the last inspection date. None of that information helps you decide whether this specific command should succeed or fail.
The Aggregate is lean. It holds only decision-relevant state.
For our library example, a properly designed Book Aggregate might look like this:
That's enough to decide:
- Can this book be borrowed? (
isAvailable === true) - Can this person return it? (
currentBorrower === personId)
Everything else, every other piece of information about the book, belongs somewhere else. It belongs in Read Models, not in the Aggregate.
The Read Model Misconception¶
Once developers accept that an Aggregate has certain fields, the next mistake follows naturally: "If my Aggregate has these fields, my Read Model table should have these fields too."
The result is predictable. They create a books table with columns for id, title, author, isbn, borrower, dueDate, location, condition, purchasePrice, and every other field they can think of. Queries become complex joins across this monolithic structure. Performance suffers. Flexibility disappears.
This is CRUD thinking applied to Event Sourcing. The events exist, but they're just a transport layer. The system still revolves around a single canonical representation of the data, just like a traditional relational database.
What Read Models Actually Are¶
Read Models are projections optimized for specific queries. They serve use cases, not data structures. And here's the crucial insight: Read Models are derived from events, not from Aggregates.
Your Aggregate decides what happens. Events record what happened. Read Models are built from those events to answer specific questions efficiently. There is no requirement, no rule, no architectural principle that says Read Models must mirror the structure of Aggregates.
In fact, the opposite is true. From one event stream, you can build many different Read Models. This is the power of CQRS that gets lost when you think in tables.
The Library Example: One Write Model, Many Read Models¶
Let's make this concrete with our library. We have a Book Aggregate that handles decisions:
Events flow through the system: BookAcquired, BookBorrowed, BookReturned, BookRemoved, and so on. These events contain rich information about what happened.
Now consider the different questions people need answered:
The Catalog Search page needs to show available books with their titles, authors, and ISBNs. It doesn't care about borrowing history or physical location.
The Member Dashboard (the "My Books" page) needs to show which books the member has borrowed, when they're due, and whether any are overdue. It doesn't need ISBNs or physical locations.
The Librarian Statistics panel needs to know which books are most popular, average borrowing durations, and trends over time. It doesn't need current availability.
The Overdue Books report needs borrower names, contact information, book titles, and how many days overdue. It doesn't need purchase prices or condition ratings.
The Inventory Management system needs physical locations, condition assessments, and last inspection dates. It doesn't need borrower information.
Each of these is a separate Read Model, built from the same events, optimized for its specific use case.
Many Small Read Models Instead of One Big Table¶
Here's what those Read Models might look like:
Catalog Search Read Model:
Borrower Dashboard Read Model:
Librarian Statistics Read Model:
{
bookId: string
title: string
totalBorrows: number
averageDuration: number
popularityRank: number
}
Overdue Books Read Model:
{
bookId: string
title: string
borrowerId: string
borrowerName: string
contactEmail: string
daysOverdue: number
}
Inventory Read Model:
Each Read Model:
- Has only the fields needed for its use case
- Can be stored in a different database if needed (PostgreSQL for transactions, Elasticsearch for search, Redis for fast lookups)
- Can be rebuilt from events at any time
- Evolves independently of other Read Models
The Multiplication Effect¶
This is where Event Sourcing truly shines. From one stream of events, you derive many specialized Read Models. Each is small, focused, and fast. Adding a new Read Model doesn't require changing the Write Model or existing Read Models. You simply build another projection from the same events.
Need a new report? Create a new Read Model. Need to optimize a slow query? Restructure that specific Read Model without touching anything else. Need to support a new use case? Add another projection.
This flexibility is the promise of CQRS. But it only materializes when you stop thinking of Read Models as mirrors of your Aggregates.
Why This Matters¶
The practical benefits are significant.
Performance improves because each Read Model is small and specialized. Queries hit exactly the data they need, nothing more. Indexes can be tuned for specific access patterns.
Flexibility increases because you can add, modify, or remove Read Models without affecting the Write Model or other Read Models. Teams can own their Read Models independently.
Clarity emerges because each Read Model has a clear purpose. There's no ambiguity about what data is for what use case. The structure of each Read Model reflects the questions it answers.
Independence follows because different teams can work on different Read Models without coordinating on schema changes. The events are the contract, not the database tables.
Unlearn the Table¶
The hardest part of Event Sourcing is unlearning the mental models that served you well in CRUD systems. Objects and tables are useful concepts, but they're not the right lens for understanding Aggregates and Read Models.
Stop asking "What fields does my Aggregate have?" Start asking "What do I need to know to make this decision?"
Stop asking "What table do I need for this Aggregate?" Start asking "What questions do my users need answered?"
The Aggregate is your decision boundary, lean and focused. Events are your historical record of what happened. Read Models are your optimized views for specific queries.
These are three different concepts. They don't need to have the same structure. In fact, they probably shouldn't.
Where to Go From Here¶
If you want to understand more about the separation of Commands and Queries, read CQRS Without The Complexity. It explains how projections work and why separating reads from writes gives you flexibility.
If you've ever felt overwhelmed by DDD terminology, If You Apply DDD to DDD, You Won't Get DDD explores why the patterns have overshadowed the essence and how events can be a simpler path to domain understanding.
And if you've been naming your events Created, Updated, Deleted, the post on killing your users explains why CRUD language robs your events of meaning.
The mental shift takes time. But once you see Aggregates as decision boundaries and Read Models as use case optimizations, the whole architecture becomes clearer. The events are the source of truth. Everything else derives from them, shaped by purpose, not by habit.