CQRS Without The Complexity¶
Imagine you're standing at the counter of your local library. You want to borrow a book – let's say it's "2001: A Space Odyssey". You tell the librarian, they check if the book is available, stamp your card, and hand you the book. Simple, right?
But wait. Before you could borrow that book, something else had to happen: the library needed to acquire it first. Someone had to order it, receive it, catalog it, and put it on the shelf. And before any of that, the library itself had to exist as an institution with systems and processes.
This seemingly simple interaction – borrowing a book – reveals something fundamental about how systems work. There's a clear separation between doing things (acquiring books, lending them out) and knowing things (which books are available, who borrowed what). This separation is at the heart of CQRS.
The Journey of a Command¶
Let's start at the very beginning. Our library needs to acquire a new book. Someone – perhaps a librarian or the procurement department – decides to add "2001: A Space Odyssey" to the collection.
In a CQRS system, this intent is expressed as a Command:
class AcquireBookCommand {
constructor(public data: {
bookId: string;
title: string;
author: string;
isbn: string;
acquiredBy: string;
}) {}
}
const command = new AcquireBookCommand({
bookId: '/books/42',
title: '2001 – A Space Odyssey',
author: 'Arthur C. Clarke',
isbn: '978-0756906788',
acquiredBy: '/librarian/jane'
});
A Command is simply a request to do something. It's imperative – it expresses an intention to change the state of the system. When this command arrives, business logic kicks in. Should we acquire this book? Do we already have it? Is the ISBN valid?
If everything checks out, something important happens: the system doesn't just update a database record. Instead, it records what happened as a fact – an Event:
class BookAcquiredEvent {
constructor(public data: {
bookId: string;
title: string;
author: string;
isbn: string;
acquiredAt: Date;
acquiredBy: string;
}) {}
}
const event = new BookAcquiredEvent({
bookId: '/books/42',
title: '2001 – A Space Odyssey',
author: 'Arthur C. Clarke',
isbn: '978-0756906788',
acquiredAt: new Date('2025-02-17T13:37:00Z'),
acquiredBy: '/librarian/jane'
});
Notice the past tense: BookAcquired, not AcquireBook. This event is a historical fact. It represents something that has already happened and cannot be changed.
This is the "C" in CQRS – the Command side. Commands express what we want to do, business logic decides if it's allowed, and events record what actually happened. But CQRS has another side, which we'll explore later: the Query side for reading data.
Now let's fast forward. A few days later, you walk into the library and want to borrow that very book:
class BorrowBookCommand {
constructor(public data: {
bookId: string;
borrowedBy: string;
}) {}
}
const command = new BorrowBookCommand({
bookId: '/books/42',
borrowedBy: '/readers/23',
});
Again, business logic runs: Is the book available? Is the member's card valid? If yes, another event is recorded:
class BookBorrowedEvent {
constructor(public data: {
bookId: string;
borrowedBy: string;
borrowedAt: Date;
dueDate: Date;
}) {}
}
const event = new BookBorrowedEvent({
bookId: '/books/42',
borrowedBy: '/readers/23',
borrowedAt: new Date('2025-08-12T10:23:00Z'),
dueDate: new Date('2025-09-27T23:59:59Z')
});
Subjects: Keeping Things Organized¶
You might wonder: how does the system know which book we're talking about? How do we distinguish between different copies of "2001: A Space Odyssey" or between completely different books?
This is where the concept of a Subject (or Stream) comes in. Each book in our library has its own unique identifier – /books/42, /books/17, and so on. All events related to a specific book are stored together, associated with that book's subject.
Think of it as a timeline for each individual book. Book /books/42 has its own history: when it was acquired, when it was borrowed, when it was returned. Book /books/17 has a completely different history. By organizing events this way, the system can always reconstruct the current state of any specific book by replaying its events.
The slash notation (/books/42) creates a hierarchical structure. You could even have sub-streams like /books/42/pages/15 if you needed to track events at a more granular level, though that's rarely necessary. The key is that subjects provide a clear way to group related events together.
If you're familiar with Domain-Driven Design (DDD), you might recognize this as similar to the concept of an Aggregate, but we don't need to dive into that complexity here.
Where Do These Events Go?¶
Now we have Commands coming in and Events going out. But where do these events actually live?
Here's something important: CQRS itself doesn't dictate how you store your data. You could, theoretically, store these events in a relational database, a document store, or even flat files. CQRS is a pattern about separating the responsibility of changing state from reading state – not about the underlying storage mechanism.
However, there's a storage approach that pairs incredibly naturally with CQRS: Event Sourcing.
Event Sourcing: A Perfect Match¶
Event Sourcing means storing events as your primary source of truth. Instead of keeping just the current state of a book (like "currently borrowed by /readers/23"), you keep the entire history of everything that happened to that book.
Here's what the event stream for /books/42 might look like:
[
new BookAcquiredEvent({
bookId: '/books/42',
acquiredAt: new Date('2025-02-17T13:37:00Z'),
...
}),
new BookBorrowedEvent({
bookId: '/books/42',
borrowedBy: '/readers/23',
borrowedAt: new Date('2025-08-12T10:23:00Z'),
...
}),
new BookReturnedEvent({
bookId: '/books/42',
returnedBy: '/readers/23',
returnedAt: new Date('2025-09-27T21:12:00Z'),
...
}),
new BookBorrowedEvent({
bookId: '/books/42',
borrowedBy: '/readers/89',
borrowedAt: new Date('2025-10-06T04:20:00Z'),
...
})
]
Want to know the current state of the book? Replay all its events. Want to know its complete history? You already have it. Want to answer questions like "How many times was this book borrowed in the last year?" – the data is right there.
This is where specialized databases come into play. Traditional databases are built around storing current state. Event sourcing databases – like EventSourcingDB (on whose documentation site you're reading this blog!) – are purpose-built for storing and querying event streams efficiently. They understand subjects, event ordering, and the unique requirements of event-sourced systems.
The Read Side: Projections and Read Models¶
Now we get to the "Q" in CQRS: Queries.
Think about what people want to know about our library:
- Which books are currently available?
- What's the most popular book this month?
- How many books does
/readers/23currently have borrowed? - What's the average borrowing duration?
These are all read operations, and they have very different characteristics from Commands:
- They don't change anything
- They need to be fast
- They often require different data structures than what makes sense for writes
- Different users need different views of the same data
This is where Projections come in. A projection takes the event stream and builds a specialized Read Model optimized for specific queries.
Here's the beautiful part: from one event stream, you can build many different read models.
Example 1: Available Books¶
// Projection: Build a list of available books
function projectAvailableBooks(events) {
const books = {}
for (const event of events) {
switch (event.constructor) {
case BookAcquiredEvent:
books[event.data.bookId] = {
title: event.data.title,
isbn: event.data.isbn,
available: true
}
break
case BookBorrowedEvent:
books[event.data.bookId].available = false
break
case BookReturnedEvent:
books[event.data.bookId].available = true
break
}
}
return Object.values(books).filter(book => book.available)
}
This read model is perfect for answering "What can I borrow right now?"
Example 2: Member Statistics¶
But maybe we want different information. Let's build a read model for member statistics:
// Projection: Build member borrowing statistics
function projectMemberStats(events) {
const stats = {}
for (const event of events) {
switch (event.constructor) {
case BookBorrowedEvent:
if (!stats[event.data.borrowedBy]) {
stats[event.data.borrowedBy] = { totalBorrowed: 0, currentlyBorrowed: 0 }
}
stats[event.data.borrowedBy].totalBorrowed++
stats[event.data.borrowedBy].currentlyBorrowed++
break
case BookReturnedEvent:
stats[event.data.returnedBy].currentlyBorrowed--
break
}
}
return stats
}
Same events, completely different view. This is the power of projections.
Example 3: Search Index¶
You could build another projection that feeds into a full-text search index, allowing members to search for books by title, author, or subject. Or a projection that calculates popular books based on borrowing frequency. Or one that tracks overdue books for the librarian's dashboard.
Each read model is:
- Purpose-built for specific queries
- Independently optimized (you could store one in PostgreSQL, another in Elasticsearch, another in Redis)
- Derived from the same source of truth: the event stream
When a new event is written, all relevant projections can be updated. This is often done asynchronously – the command side doesn't wait for all read models to update before confirming success.
Why Separate Commands and Queries?¶
At this point, you might ask: why go through all this trouble? Why not just have a traditional database with tables for books and borrowings?
The answer lies in recognizing that writing and reading have fundamentally different requirements:
Writing (Commands)¶
- Enforces business rules and invariants
- Validates that actions are allowed
- Protects data integrity
- Typically handles lower volume
- Needs strong consistency
Reading (Queries)¶
- Optimized for different access patterns
- Often handles much higher volume
- Needs to be fast
- Can tolerate slight delays
- Requires different indexes and structures
By separating these concerns, you gain:
Flexibility: Want to add a new view? Build a new projection. No need to change your write model or add complex joins.
Scalability: Read and write sides can scale independently. Most systems are read-heavy – now you can optimize accordingly.
Simplicity: Each side can be as simple as possible for its purpose. Your write side focuses purely on business logic. Your read side focuses purely on query performance.
Evolution: Need to change how data is displayed? Update a projection. Your historical events remain unchanged, and you can rebuild read models at any time.
A Word About Eventual Consistency¶
Here's something important to understand: in a CQRS system, there's typically a small delay between when a command succeeds and when the read models reflect that change. This is called eventual consistency.
When you borrow a book, the BookBorrowed event is written immediately, and the command returns success. But updating all the read models – the available books list, your member statistics, the search index – happens asynchronously, perhaps milliseconds or seconds later.
Is this a problem? In practice, almost never.
Consider the real-world library: when you borrow a book, there's a delay before it shows as unavailable in the system. Someone else might see it as available for a few seconds. But this doesn't break anything – if they try to borrow it, the command will fail because the business logic checks the actual state.
Traditional systems have the same issue. Your browser shows you cached data. Database replicas lag behind the primary. There's always some delay. CQRS just makes it explicit and works with it instead of pretending it doesn't exist.
In most user interfaces, users expect a slight delay anyway. When you submit a form, you see a loading spinner. By the time the next page loads, your read models are updated. The few cases where you truly need immediate consistency (like showing "You borrowed this book!" right after the action) can be handled by the UI directly, without waiting for projections.
Getting Started: Keep It Simple¶
If you're new to CQRS, here's the good news: you don't need to build everything at once.
Start small:
- Identify one area where reads and writes have different needs
- Express intentions as commands
- Record what happened as events
- Build one simple projection for your most common queries
You don't need separate databases initially. You don't need complex event sourcing infrastructure. You can start with CQRS as a pattern in your existing architecture.
If you're working on the JVM, frameworks like OpenCQRS can help you get started quickly. They provide the building blocks for commands, events, and projections, so you don't have to implement everything from scratch.
As your needs grow, you can:
- Add more projections
- Introduce event sourcing for some or all of your domains
- Scale read and write sides independently
- Use specialized databases like EventSourcingDB for better event stream management
The Big Picture¶
CQRS is fundamentally about recognizing that doing something and asking about something are different operations with different needs. By treating them separately:
- Your write side stays focused on business logic and maintaining consistency
- Your read side stays focused on serving data efficiently
- Both can evolve independently
- Your system becomes more flexible and scalable
Event Sourcing complements this beautifully by making events your source of truth. Instead of storing just the current state, you store the complete history, which naturally feeds into multiple specialized read models through projections.
This might seem like more complexity than a traditional CRUD application, but it's really just making explicit what good systems do implicitly: separate concerns, maintain history, and optimize different operations differently.
The library doesn't use the same system for acquiring books and for helping people find books to borrow. Neither should your application.
Start simple, stay pragmatic, and let the pattern emerge where it provides value. That's CQRS without the complexity.