CQRS Pattern: A Practical Implementation Guide
I was reviewing a pull request last week when I noticed something odd. The same database query appeared in three different places — once for displaying a dashboard, once for generating a report, and once for validating a business rule. Each version had slightly different fields, different joins, and different performance characteristics.
“Why don’t we just use one query?” I asked.
The senior engineer sighed. “Because what we need to read is completely different from what we need to write. We’ve been fighting this for months.”
That conversation led me down a rabbit hole into CQRS — Command Query Responsibility Segregation. It’s a pattern I’d heard mentioned in architecture meetings but never fully understood. After spending time watching how teams implement it (and sometimes struggle with it), I want to share what I’ve learned.
Here’s the thing: CQRS is powerful, but it’s also one of the most over-applied patterns in software architecture. Martin Fowler himself warns that “for most systems CQRS adds risky complexity.” So let’s talk about when it actually makes sense, and how to implement it without shooting yourself in the foot.
What CQRS Actually Is (And Isn’t)
CQRS stands for Command Query Responsibility Segregation. The core idea is deceptively simple: use different models for reading data (queries) and writing data (commands).
That’s it. That’s the pattern.
But here’s where confusion creeps in. CQRS is often conflated with:
- Event Sourcing (storing events instead of state)
- Eventual consistency (reads lagging behind writes)
- Separate databases (physically different storage)
- Microservices (distributed systems)
None of these are required for CQRS. You can implement CQRS with a single database, synchronous updates, and a monolithic application. The pattern is about model separation, not infrastructure complexity.
Greg Young, who coined the term, describes it simply: “CQRS is simply the creation of two objects where there was previously only one.” The command model handles writes. The query model handles reads. They can share a database, run in the same process, and update synchronously.
The simplest CQRS implementation might just be two different classes or services — one optimized for handling commands, another optimized for queries. No event buses, no separate databases, no eventual consistency headaches.
Why Would You Want Separate Models?
The traditional CRUD approach uses one model for everything. You read from it, write to it, validate with it, display from it. This works great until it doesn’t.
The Problem with Single Models
I watched a team struggle with this exact issue. They had an e-commerce system where:
For writes (placing an order):
- Validate inventory levels
- Check customer credit
- Apply business rules
- Calculate pricing with discounts
- Update multiple related entities atomically
For reads (displaying order history):
- Join orders with products, customers, shipping info
- Aggregate totals and statistics
- Format for different views (mobile, web, reports)
- Handle pagination and filtering
- Optimize for fast response times
The single Order model became a monster. It had fields needed only for validation, methods needed only for display, and performance optimizations that conflicted with each other. Every change risked breaking something.
How CQRS Helps
With CQRS, you’d have:
Command Model (Write Side):
class PlaceOrderCommand:
customer_id: str
items: List[OrderItem]
shipping_address: Address
payment_method: PaymentInfo
class OrderCommandHandler:
def handle(self, command: PlaceOrderCommand) -> OrderId:
# Rich domain logic
# Validation
# Business rules
# Atomic writes
passQuery Model (Read Side):
class OrderSummaryQuery:
customer_id: str
page: int
page_size: int
class OrderSummaryView:
order_id: str
total: Decimal
status: str
item_count: int
placed_at: datetime
# Denormalized, optimized for display
class OrderQueryHandler:
def handle(self, query: OrderSummaryQuery) -> List[OrderSummaryView]:
# Simple, fast reads
# No business logic
# Optimized queries
passThe write side focuses on correctness and business rules. The read side focuses on performance and presentation. Neither compromises for the other.
The Spectrum of CQRS Implementations
Here’s something I wish someone had told me earlier: CQRS exists on a spectrum. You don’t have to go all-in.
Level 1: Logical Separation (Same Database, Same Process)
The simplest form. You have separate code paths for reads and writes, but they share everything else.
When to use: Your read and write models are diverging, but you don’t need independent scaling or can’t afford the complexity of separate storage.
Example: A content management system where articles are edited through a rich domain model but displayed through simple, denormalized views.
Level 2: Separate Read Database (Synchronized)
The write side uses a normalized database optimized for transactions. The read side uses a denormalized database (or views) optimized for queries. Updates are synchronized, often through database triggers or application-level sync.
When to use: Read performance is critical, and you can tolerate slight delays in read consistency.
Example: An analytics dashboard that needs sub-second query response times but can tolerate data being a few seconds stale.
Level 3: Event-Driven Synchronization
The write side publishes events when state changes. The read side subscribes to these events and updates its models accordingly. This introduces eventual consistency.
When to use: You need independent scaling of reads and writes, or you’re already using event-driven architecture.
Example: A high-traffic e-commerce site where product catalog reads vastly outnumber inventory updates.
Level 4: Full Event Sourcing + CQRS
The write side stores events as the source of truth (Event Sourcing). The read side builds materialized views from these events. This is the most complex but also most powerful form.
When to use: You need complete audit trails, temporal queries, or the ability to rebuild state from history.
Get OverTheHead’s stories in your inbox
Join Medium for free to get updates from this writer.
Example: Financial systems, healthcare records, or any domain where “what happened and when” is as important as “what is the current state.”
A Practical Implementation Example
Let me walk through a realistic example: a task management system. We’ll implement Level 2 CQRS — separate read models with synchronized updates.
The Domain: Task Management
Users can:
- Create tasks with titles, descriptions, due dates
- Assign tasks to team members
- Update task status (todo → in progress → done)
- View their task dashboard with filters and aggregations
The Write Side (Commands)
# commands.py
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from uuid import UUID
@dataclass
class CreateTaskCommand:
title: str
description: str
due_date: Optional[datetime]
assignee_id: Optional[UUID]
created_by: UUID
@dataclass
class UpdateTaskStatusCommand:
task_id: UUID
new_status: str # 'todo', 'in_progress', 'done'
updated_by: UUID
@dataclass
class AssignTaskCommand:
task_id: UUID
assignee_id: UUID
assigned_by: UUID
# command_handlers.py
class TaskCommandHandler:
def __init__(self, task_repository, event_publisher):
self.task_repository = task_repository
self.event_publisher = event_publisher
def handle_create(self, command: CreateTaskCommand) -> UUID:
# Domain logic and validation
task = Task.create(
title=command.title,
description=command.description,
due_date=command.due_date,
assignee_id=command.assignee_id,
created_by=command.created_by
)
# Persist
self.task_repository.save(task)
# Publish event for read model sync
self.event_publisher.publish(TaskCreatedEvent(
task_id=task.id,
title=task.title,
assignee_id=task.assignee_id,
due_date=task.due_date,
created_at=task.created_at
))
return task.id
def handle_status_update(self, command: UpdateTaskStatusCommand):
task = self.task_repository.get(command.task_id)
# Domain validation
task.update_status(command.new_status, command.updated_by)
self.task_repository.save(task)
self.event_publisher.publish(TaskStatusChangedEvent(
task_id=task.id,
old_status=task.previous_status,
new_status=task.status,
changed_at=datetime.utcnow()
))The Read Side (Queries)
# queries.py
@dataclass
class GetUserDashboardQuery:
user_id: UUID
status_filter: Optional[str] = None
@dataclass
class GetTeamOverviewQuery:
team_id: UUID
include_completed: bool = False
# read_models.py
@dataclass
class TaskSummary:
"""Denormalized view optimized for display"""
task_id: UUID
title: str
status: str
assignee_name: str # Denormalized - no join needed
due_date: Optional[datetime]
is_overdue: bool # Pre-calculated
days_until_due: Optional[int] # Pre-calculated
@dataclass
class UserDashboard:
user_id: UUID
total_tasks: int
tasks_by_status: Dict[str, int] # Pre-aggregated
overdue_count: int
tasks: List[TaskSummary]
@dataclass
class TeamOverview:
team_id: UUID
member_workloads: Dict[str, int] # Pre-aggregated
completion_rate: float # Pre-calculated
tasks_due_this_week: int
# query_handlers.py
class TaskQueryHandler:
def __init__(self, read_db):
self.read_db = read_db
def get_user_dashboard(self, query: GetUserDashboardQuery) -> UserDashboard:
# Simple, fast query against denormalized read store
# No complex joins, no business logic
return self.read_db.execute("""
SELECT * FROM user_dashboard_view
WHERE user_id = %s
""", query.user_id)
def get_team_overview(self, query: GetTeamOverviewQuery) -> TeamOverview:
return self.read_db.execute("""
SELECT * FROM team_overview_view
WHERE team_id = %s
""", query.team_id)The Synchronization Layer
# projections.py
class TaskProjection:
"""Updates read models when events occur"""
def __init__(self, read_db):
self.read_db = read_db
def handle_task_created(self, event: TaskCreatedEvent):
# Update denormalized task view
self.read_db.execute("""
INSERT INTO task_summary_view
(task_id, title, status, assignee_id, assignee_name, due_date, is_overdue)
VALUES (%s, %s, 'todo', %s,
(SELECT name FROM users WHERE id = %s),
%s, %s)
""", event.task_id, event.title, event.assignee_id,
event.assignee_id, event.due_date,
event.due_date and event.due_date < datetime.utcnow())
# Update user dashboard aggregates
self.read_db.execute("""
UPDATE user_dashboard_view
SET total_tasks = total_tasks + 1,
tasks_todo = tasks_todo + 1
WHERE user_id = %s
""", event.assignee_id)
def handle_status_changed(self, event: TaskStatusChangedEvent):
# Update task view
self.read_db.execute("""
UPDATE task_summary_view
SET status = %s
WHERE task_id = %s
""", event.new_status, event.task_id)
# Update dashboard aggregates
self._update_status_counts(event)When CQRS Makes Sense (And When It Doesn’t)
After watching several teams implement CQRS — some successfully, some painfully — I’ve developed a sense for when it’s worth the complexity.
Good Candidates for CQRS
High read/write disparity: If you have significantly more reads than writes — which is common in many applications — optimizing them separately makes sense. Social media feeds, product catalogs, and dashboards often fit this pattern.
Complex domain logic on writes: When your write operations involve significant business rules, validation, and domain modeling, separating them from simple reads keeps both clean.
Different scaling requirements: If reads need to scale horizontally while writes need strong consistency, CQRS lets you optimize each independently.
Multiple read representations: When the same data needs to be presented in fundamentally different ways (mobile app, web dashboard, API, reports), separate read models prevent a mess.
Collaborative domains: Systems where multiple users modify shared data benefit from the clear command/event model that CQRS encourages.
Poor Candidates for CQRS
Simple CRUD applications: If your app is basically a database UI with minimal business logic, CQRS adds complexity without benefit. Most admin panels, simple content management systems, and internal tools fall here.
Strong consistency requirements everywhere: If users absolutely must see their writes immediately reflected in reads, the eventual consistency that often accompanies CQRS becomes a liability.
Small teams or early-stage products: CQRS increases the surface area of your codebase. If you’re a small team trying to move fast, the overhead may not be worth it.
Unclear domain boundaries: CQRS works best when you understand your domain well. If you’re still discovering what your system needs to do, premature CQRS will lock in the wrong abstractions.
Martin Fowler puts it well: “For most systems CQRS adds risky complexity.” The pattern shines in specific situations, but those situations are the minority.
Common Pitfalls (And How to Avoid Them)
Pitfall 1: Going Full Event Sourcing Too Early
I’ve seen teams jump straight to Event Sourcing + CQRS because it sounds architecturally pure. Six months later, they’re drowning in event versioning, projection rebuilds, and debugging nightmares.
The fix: Start with the simplest CQRS that solves your problem. You can always add Event Sourcing later if you genuinely need it.
Pitfall 2: Ignoring Eventual Consistency UX
When reads lag behind writes, users get confused. “I just updated that — why doesn’t it show?”
The fix: Design your UI to handle this. Options include:
- Optimistic updates (show the change immediately, sync in background)
- “Your changes are being processed” messaging
- Polling or websockets to refresh when sync completes
- Read-your-writes consistency for the user who made the change
Pitfall 3: Over-Denormalizing Read Models
It’s tempting to create a read model for every possible query. You end up with dozens of projections, each needing maintenance when the domain changes.
The fix: Start with a few general-purpose read models. Add specialized ones only when you have proven performance needs.
Pitfall 4: Forgetting About Failure Modes
What happens when the event bus goes down? When a projection fails mid-update? When events arrive out of order?
The fix: Design for failure from the start:
- Idempotent projections (can safely replay events)
- Dead letter queues for failed events
- Monitoring and alerting on projection lag
- Ability to rebuild projections from scratch
Pitfall 5: CQRS Everywhere
Some teams, excited about the pattern, apply it to every service. This creates unnecessary complexity in places where simple CRUD would suffice.
The fix: Apply CQRS at the bounded context level, only where it provides clear benefits. As Fowler notes, “CQRS should only be used on specific portions of a system… and not the system as a whole.”
Key Takeaways
- CQRS separates read and write models — that’s the core idea. Everything else (event sourcing, separate databases, eventual consistency) is optional.
- Start simple — logical separation in code is often enough. Add infrastructure complexity only when you have proven needs.
- It’s not for every system — CRUD works fine for most applications. CQRS shines when read/write patterns diverge significantly.
- Eventual consistency has UX implications — if you introduce it, design your interface to handle it gracefully.
- The pattern exists on a spectrum — from simple code separation to full event sourcing. Choose the level that matches your actual requirements.
- Domain understanding matters — CQRS works best when you deeply understand your domain’s read and write patterns.
References
- Fowler, M. “CQRS.” martinfowler.com, July 2011. https://martinfowler.com/bliki/CQRS.html
- Young, G. “CQRS Documents.” cqrs.files.wordpress.com, November 2010. https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf
- Richardson, C. “Pattern: Command Query Responsibility Segregation (CQRS).” microservices.io, 2024. https://microservices.io/patterns/data/cqrs.html
- Microsoft. “CQRS pattern.” Azure Architecture Center, 2024. https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs
- Vernon, V. Implementing Domain-Driven Design. Addison-Wesley Professional, 2013.
- Kleppmann, M. Designing Data-Intensive Applications. O’Reilly Media, 2017.
Until next time, keep observing, keep learning.
— The Architect’s Notebook
I’m a Software Engineer learning architecture by watching architects work. If these field notes help you understand architecture better, consider following for more observations every Week.
What’s your experience with CQRS? Have you implemented it successfully, or learned hard lessons from over-applying it? I’d love to hear about the real-world trade-offs you’ve encountered — especially the ones that aren’t in the textbooks.









