DEV Community

Cover image for Your Database is a Bottleneck. It's Time to Split Your Reads and Writes with CQRS.
PasanAbeysekara
PasanAbeysekara

Posted on

Your Database is a Bottleneck. It's Time to Split Your Reads and Writes with CQRS.

You’ve seen it before. Maybe you’re living it right now.

It starts with a simple e-commerce application. You design a beautiful, normalized database schema. Users, Products, Orders, OrderItems—all linked with pristine foreign keys. It’s a work of art in Third Normal Form. For a while, everything is great.

Then, the business grows.

The marketing team wants a complex dashboard showing sales trends, top customers, and regional performance. The queries to generate this data are massive JOINs that lock tables and slow down the checkout process.

During a flash sale, the write-load skyrockets. Thousands of new orders flood the Orders table. The database groans under the pressure of transactional writes, and simultaneously, the "My Previous Orders" page for every customer starts to time out.

Developers become fearful. "We can't add another index to the Products table; it will slow down writes." "We can't denormalize that table for faster reads; it will break transactional consistency."

Your database, the heart of your application, is suffering from an identity crisis. It's being asked to be two completely different things at once: a lightning-fast, highly-consistent transactional processor, AND a flexible, high-performance data warehouse. It's trying to serve two masters, and failing at both.

There is a better way. It’s time to stop forcing one model to do two jobs. It’s time to talk about Command Query Responsibility Segregation (CQRS).

The Core Problem: The Single Model Schizophrenia

The root of this pain is a fundamental conflict. The optimal model for changing data is almost never the optimal model for reading data.

The Write Side (The "Command" Side)

When your application needs to change something—a user updates their profile, a customer places an order, an admin adds a product—it's executing a Command. The data model that serves these commands needs to be:

  • Normalized: To avoid data duplication and ensure integrity. You store the userID in the Orders table, not the user's entire name and address.
  • Consistent: It must enforce all business rules and invariants. You can't place an order for a product that's out of stock. The total price must always equal the sum of its items.
  • Transactional: The entire operation must succeed or fail as a single unit. An order and its corresponding line items are created together, or not at all.

This is the world of Online Transaction Processing (OLTP). It’s optimized for a high volume of small, fast, consistent writes.

Our write model is built for integrity. It’s a fortress of rules and constraints, which is exactly what we want when handling commands.

The Read Side (The "Query" Side)

When your application needs to display something—a user’s order history, a product catalog, that complex sales dashboard—it's executing a Query. The data model that serves these queries needs to be:

  • Denormalized: To avoid expensive JOINs at read time. For the "My Orders" page, you want a single record that already contains the customer's name, the order total, and the number of items, without joining five tables.
  • Optimized for a specific view: The dashboard needs data aggregated by week. The product search needs a flat structure with all filterable attributes. The user profile page needs a different shape entirely.
  • Blazingly Fast: Users expect pages to load instantly. Reads should be cheap and quick.

This is the world of Online Analytical Processing (OLAP). It’s optimized for a high volume of complex reads.

Forcing a single, normalized database to handle both of these workloads is like asking a world-class sprinter to also win a weightlifting competition. They are fundamentally different disciplines.

The Solution: Command Query Responsibility Segregation (CQRS)

Coined by Greg Young, CQRS is a pattern that builds on a simple principle from Bertrand Meyer:

Asking a question should not change the answer.

In other words, methods that return data (Queries) should be separate from methods that change data (Commands). CQRS takes this a step further: it suggests using a completely different model for reads and writes.

This doesn't have to be complicated at first. Let's look at a simple code-level implementation.

Before CQRS: The Swiss Army Knife Service

A typical service might look like this. The UserService uses a single User entity (maybe from an ORM) for both operations.

// A typical "CRUD" style service class UserService { constructor(private dbContext: DbContext) {} // A Command public async updateUserAddress(userId: string, newAddress: string): Promise<void> { const user = await this.dbContext.Users.find(userId); if (!user) { throw new Error("User not found"); } user.address = newAddress; // Mutating the entity await this.dbContext.SaveChanges(); } // A Query public async getUserProfile(userId: string): Promise<UserProfileDto> { const user = await this.dbContext.Users.find(userId); // ...map the complex User entity to a simpler DTO for the UI return mapToUserProfileDto(user); } } 
Enter fullscreen mode Exit fullscreen mode

After CQRS: The Specialist Services

With CQRS, we split this into two distinct paths.

The Command Path:

// Command objects are explicit representations of intent export class UpdateUserAddressCommand { constructor(public readonly userId: string, public readonly newAddress: string) {} } // A handler focused solely on executing the command class UserCommandHandler { constructor(private writeDbContext: WriteDbContext) {} public async handle(command: UpdateUserAddressCommand): Promise<void> { const user = await this.writeDbContext.Users.find(command.userId); // ... complex business logic and validation here ... user.address = command.newAddress; await this.writeDbContext.SaveChanges(); } } 
Enter fullscreen mode Exit fullscreen mode

The Query Path:

// A service focused on building read models class UserQueryService { constructor(private readDbContext: ReadDbContext) {} // This might use a different database, or even optimized raw SQL public async getUserProfile(userId: string): Promise<UserProfileDto> { // This DTO could map directly to a denormalized table or view const sql = "SELECT user_id, full_name, profile_avatar_url FROM user_profile_read_models WHERE user_id = @userId"; return await this.readDbContext.query<UserProfileDto>(sql, { userId }); } } 
Enter fullscreen mode Exit fullscreen mode

Notice the separation of concerns. The command handler deals with business logic and transactions. The query service is dumb and fast; its only job is to fetch data that has already been prepared for it.

This separation allows us to go even further and split the physical databases.

The CQRS architecture. Commands update a normalized write DB. A synchronization process updates one or more denormalized read DBs, which are queried directly.

In this architecture:

  1. The API Gateway routes POST/PUT/DELETE requests to the Command Service.
  2. The Command Service executes business logic against the Write Database (e.g., a normalized SQL Server or PostgreSQL instance).
  3. Some Synchronization Mechanism (we'll get to this!) is responsible for updating the read models.
  4. The API Gateway routes GET requests to the Query Service.
  5. The Query Service performs simple, fast lookups against the Read Database (e.g., a denormalized table, a document DB like MongoDB, or a search index like Elasticsearch).

This is a huge improvement. But it begs a question: what is that "Synchronization Mechanism"? And what if our source of truth, the write database, was even more powerful?

Leveling Up: Introducing Event Sourcing (ES)

Let's think about a bank account. When you look at your balance, you're seeing the current state. But the bank doesn't just store your balance. It stores every single transaction—every deposit and withdrawal. That list of transactions is the real source of truth. Your balance is just a calculation, a projection, based on that list.

This is the core idea of Event Sourcing.

Instead of storing the current state of an entity, you store the full sequence of immutable events that have ever happened to it.

The write model is no longer a table with UPDATE and DELETE statements. It's an append-only log of facts.

  • Instead of UPDATE Users SET Address = '123 Main St', we append an UserAddressChanged event.
  • Instead of INSERT INTO Orders..., we append an OrderPlaced event.
  • Instead of DELETE FROM Products..., we append a ProductDiscontinued event.

These events are small, immutable objects representing something that happened in the past.

// Example Events class UserRegistered { constructor(public readonly userId: string, public readonly name: string, public readonly timestamp: Date) {} } class UserAddressChanged { constructor(public readonly userId: string, public readonly newAddress: string, public readonly timestamp: Date) {} } 
Enter fullscreen mode Exit fullscreen mode

An entity, like an Order or a User, is called an Aggregate. Its state is not stored directly. Instead, it's rebuilt on-the-fly by replaying its event stream.

class UserAggregate { id: string; name: string; address: string; private changes: object[] = []; // A list of uncommitted events // Reconstitute state by replaying events public static fromEvents(events: object[]): UserAggregate { const user = new UserAggregate(); events.forEach(event => user.apply(event)); user.changes = []; // Clear changes after loading return user; } // A command method that produces an event public changeAddress(newAddress: string): void { if (this.address === newAddress) { return; // No change needed } // Business logic would go here this.enqueue(new UserAddressChanged(this.id, newAddress, new Date())); } // A private method to apply events to the state private apply(event: any): void { if (event instanceof UserRegistered) { this.id = event.userId; this.name = event.name; } else if (event instanceof UserAddressChanged) { this.address = event.newAddress; } } private enqueue(event: object): void { this.apply(event); this.changes.push(event); } } 
Enter fullscreen mode Exit fullscreen mode

The database that stores these append-only logs is called an Event Store. It's the ultimate write model. It's the absolute, undeniable source of truth for your entire system.

The Perfect Synergy: CQRS + ES

Now, let's put it all together. Event Sourcing is the perfect engine for the write side of a CQRS architecture. The "Synchronization Mechanism" is no longer a mystery; it's a direct consequence of using events.

Here is the complete, modern, and incredibly powerful architecture:

The synergy of CQRS and Event Sourcing. Commands generate events. Events are published. Projectors consume events to build bespoke read models.

Let's walk through the flow for updating a user's address:

  1. A UpdateUserAddressCommand arrives at the Command Service.
  2. The service loads the UserAggregate by fetching all its past events from the Event Store (e.g., UserRegistered, UserNameChanged, PreviousAddressChanged). It replays them to get the current state.
  3. It calls the user.changeAddress() method. This performs validation and produces a new UserAddressChanged event.
  4. The service appends this new event to the user's stream in the Event Store. This is the only write operation.
  5. The Event Store, upon successfully saving the event, publishes it to a Message Bus (like Kafka, RabbitMQ, or AWS Kinesis).
  6. Multiple independent services, called Projectors, are listening on the message bus.
    • A UserProfileProjector receives the UserAddressChanged event and updates the user_profile_read_models table in a PostgreSQL database.
    • A SearchIndexProjector receives the same event and updates the user's document in an Elasticsearch cluster.
    • An AuditLogProjector receives the event and simply writes "User X changed their address at time Y" to a log file.

This architecture is beautiful. It unlocks capabilities that are nearly impossible with traditional models:

  • Ultimate Flexibility: Need a new feature that requires a completely new view of your data? No problem. Write a new projector, deploy it, and tell it to replay all events from the beginning of time. You can generate a brand-new read model without ever touching your core system.
  • Powerful Auditing & Debugging: You have a complete, immutable log of everything that ever happened. A customer claims their order was different yesterday? You can know for a fact. Just replay their Order event stream up to yesterday's date.
  • Temporal Queries: You can query the state of the system at any point in time. "Show me our inventory levels just before the Black Friday sale started."
  • Extreme Scalability: The write side (appending to a log) is incredibly fast. The read models can be scaled independently. If the search feature is getting heavy traffic, you just scale up the Elasticsearch cluster and its projector, with zero impact on the checkout process.

The Elephant in the Room: The Trade-offs

This power does not come for free. Adopting CQRS and Event Sourcing is a major architectural decision with significant consequences. You must be honest about the challenges.

This is not the right pattern for a simple CRUD blog or a small departmental application. This is a pattern for complex, core business domains.

  1. Eventual Consistency: This is the big one. When a command succeeds, the read models are not updated instantly. There is a small delay (usually milliseconds, but it's not zero) as the event travels through the message bus to the projectors. Your UI must be designed to handle this. You can't write a value and immediately read it back expecting the new value. Strategies like optimistic UI updates, polling, or using WebSockets are often required.

  2. Massive Architectural Complexity: You just went from a simple monolith with one database to a distributed system with an API Gateway, command services, an Event Store, a message bus, and multiple projector services with their own databases. This is a lot more to build, deploy, monitor, and manage.

  3. Developer Mindset Shift: Thinking in events is a paradigm shift. It requires un-learning the "state-based" mindset that has been dominant for decades. Debugging a distributed flow across multiple services is harder than stepping through a monolithic call stack.

  4. Event Versioning: Your business will evolve, and so will your events. What happens when you need to add a middleName field to your UserRegistered event? Old events in your store won't have this field. You need a robust strategy for versioning your event schemas and "upcasting" old events to the new format when they are read.

Conclusion: Should You Use This?

CQRS with Event Sourcing is one of the most powerful architectural patterns available to software engineers today. It provides a path to build systems that are scalable, resilient, flexible, and perfectly aligned with complex business domains.

However, it is a sharp tool for a specific set of hard problems.

You should seriously consider this pattern if:
✅ You are working in a complex, core business domain.
✅ Your application has very different and demanding requirements for reads and writes.
✅ You need a detailed audit log or the ability to inspect the system's state at any point in time.
✅ You anticipate needing many different views of the same data for different users or services.
✅ You are building a system that needs to be highly scalable and evolve over many years.

You should probably avoid this pattern if:
❌ You are building a simple CRUD application.
❌ Your team is new to distributed systems concepts.
❌ Strict, immediate consistency for all reads is a non-negotiable business requirement.
❌ Your project has a very short timeline and a low tolerance for operational overhead.

If you recognize the problems described at the beginning of this article—the slow dashboards, the database contention, the fear of change—then CQRS and Event Sourcing might not just be a good idea; they might be the key to your application's long-term survival and success. It's a journey, but for the right application, it's one worth taking.

Top comments (0)