CQRS: Separating Reads from Writes
CQRS separates your write model from your read model because they're fundamentally different problems. It's powerful for complex domains where reads and writes have different patterns, but don't use it until you actually need it.
What CQRS Actually Is
Command Query Responsibility Segregation (CQRS) separates the model you use to update data (writes) from the model you use to read data. In traditional applications, you have one model doing both. CQRS says: use two models optimized for their specific purpose.
A command is an action that changes state. "Place this order." "Delete this user." "Update this product." Commands are processed by the write model, which enforces business rules and updates the source of truth.
A query is a request for information. "Get all orders for this customer." "Find products matching these filters." "Show me the order total." Queries are processed by the read model, which is optimized for retrieving data in the shapes clients need.
In a traditional CRUD system, the same database schema serves both purposes. CQRS says: use separate schemas optimized for writes and reads.
// Traditional CRUD: One model for reads and writes
const user = database.query('SELECT * FROM users WHERE id = 1');
user.name = 'Alice';
database.update('UPDATE users SET name = ? WHERE id = 1', [user.name]);
// CQRS: Separate write and read models
// Write model (normalized, enforces rules)
const command = new UpdateUserNameCommand(userId, newName);
commandHandler.handle(command); // Updates source of truth
// Read model (denormalized, optimized for queries)
const user = readDatabase.query('SELECT * FROM user_read_model WHERE id = 1');Why It Matters
Read and Write Patterns Are Different
Most applications read far more than they write. An e-commerce site might write one order per minute but handle 1,000 reads per minute across product searches, detail views, recommendation feeds.
A single model optimized for both is optimized for neither. You're adding read-specific columns to tables (denormalization) while also enforcing write-time consistency. You get the worst of both worlds.
CQRS says: optimize reads and writes separately. Make writes transactionally consistent. Make reads fast and flexible. They're different problems.
Complex Domain Logic Needs Room to Breathe
When you're enforcing complex business rules on writes, the write model gets complicated. When you're serving diverse read patterns, the read model gets complicated. Mixing them creates cognitive overload.
Separating them means:
- Write model focuses purely on consistency and rules
- Read model focuses purely on access patterns
- Each can be optimized without compromising the other
Scalability Decoupling
If you have 10,000 reads per minute and 10 writes per minute, you can scale them independently. Add read replicas or cache layers for the read model. Keep the write model tight and consistent.
Temporal Flexibility
With CQRS, you can ask "what did this entity look like on March 1st?" by replaying events or querying historical read models. Traditional databases make this hard.
How CQRS Works
Basic Flow
Flow diagram
The Write Model
The write model is where business rules live. It's your domain model.
class PlaceOrderCommand {
constructor(customerId, items) {
this.customerId = customerId;
this.items = items; // [{productId, quantity}]
}
}
class PlaceOrderCommandHandler {
constructor(customerRepository, productRepository, eventPublisher) {
this.customers = customerRepository;
this.products = productRepository;
this.eventPublisher = eventPublisher;
}
handle(command) {
// Enforce business rules in the write model
const customer = this.customers.getById(command.customerId);
if (!customer.isActive()) {
throw new Error("Customer account is inactive");
}
// Create aggregate
const order = new Order(generateId(), customer);
for (let item of command.items) {
const product = this.products.getById(item.productId);
if (product.discontinued) {
throw new Error(`Product ${product.id} is discontinued`);
}
if (item.quantity <= 0) {
throw new Error("Quantity must be positive");
}
order.addLineItem(product, item.quantity);
}
// Calculate totals, apply discounts, enforce rules
order.calculateTotal();
if (order.total.isZero()) {
throw new Error("Order total must be greater than zero");
}
// Publish events (source of truth)
const events = order.getDomainEvents();
for (let event of events) {
this.eventPublisher.publish(event);
}
return order.id;
}
}The write model is transactionally consistent. All rules are enforced before anything is persisted.
The Read Model
The read model is for efficient retrieval. It's denormalized—shaped for the queries you actually run.
// Read model: denormalized for efficient queries
class OrderListQuery {
constructor(customerId, pageSize, pageNumber) {
this.customerId = customerId;
this.pageSize = pageSize;
this.pageNumber = pageNumber;
}
}
class OrderListQueryHandler {
constructor(readDatabase) {
this.db = readDatabase;
}
handle(query) {
// This read model might look completely different
// from the write model's Order aggregate
const sql = `
SELECT
id,
status,
total,
createdAt,
itemCount,
customerEmail
FROM order_list
WHERE customerId = ?
ORDER BY createdAt DESC
LIMIT ? OFFSET ?
`;
return this.db.query(sql, [
query.customerId,
query.pageSize,
query.pageSize * (query.pageNumber - 1)
]);
}
}
// Another read model for a different query
class OrderDetailQuery {
constructor(orderId) {
this.orderId = orderId;
}
}
class OrderDetailQueryHandler {
constructor(readDatabase) {
this.db = readDatabase;
}
handle(query) {
const sql = `
SELECT
id,
status,
total,
createdAt,
items,
shippingAddress,
paymentMethod,
trackingNumber
FROM order_details
WHERE id = ?
`;
return this.db.query(sql, [query.orderId]);
}
}Read models are shaped for specific queries. You can have multiple read models for different use cases. Notice that neither read model is the same as the write model's Order aggregate—they're denormalized for specific queries.
Keeping Them Synchronized
Events from the write model update read models. This usually happens asynchronously.
class OrderEventProjector {
constructor(readDatabase) {
this.db = readDatabase;
}
projectOrderPlaced(event) {
// When order is placed in write model, update read models
this.db.insert('order_list', {
id: event.orderId,
customerId: event.customerId,
status: 'placed',
total: event.total,
createdAt: event.timestamp,
itemCount: event.items.length,
customerEmail: event.customerEmail
});
for (let item of event.items) {
this.db.insert('order_details_items', {
orderId: event.orderId,
productId: item.productId,
quantity: item.quantity,
price: item.price
});
}
}
projectOrderShipped(event) {
// Update read models when order ships
this.db.update('order_list', {
status: 'shipped'
}, { id: event.orderId });
this.db.update('order_details', {
trackingNumber: event.trackingNumber,
status: 'shipped'
}, { id: event.orderId });
}
// Handle other events...
}
class EventBus {
constructor(projectors) {
this.projectors = projectors;
}
publish(event) {
// Write model publishes event
// Read models project it
for (let projector of this.projectors) {
const methodName = `project${event.constructor.name}`;
if (projector[methodName]) {
projector[methodName](event);
}
}
}
}CQRS with Event Sourcing
CQRS pairs beautifully with event sourcing (storing all state changes as immutable events rather than current state).
// Write model: stores events
class EventStore {
append(event) {
// Every command generates events that are stored
// Event is the source of truth
this.database.insert('events', {
aggregateId: event.aggregateId,
type: event.constructor.name,
data: JSON.stringify(event),
timestamp: new Date()
});
}
getEvents(aggregateId) {
// Reconstruct aggregate by replaying events
return this.database.query(
'SELECT * FROM events WHERE aggregateId = ? ORDER BY timestamp',
[aggregateId]
);
}
}
class Order {
static fromEvents(events) {
const order = new Order();
for (let event of events) {
order.apply(event);
}
return order;
}
apply(event) {
if (event instanceof OrderPlacedEvent) {
this.id = event.orderId;
this.customerId = event.customerId;
this.items = event.items;
this.status = 'placed';
} else if (event instanceof OrderShippedEvent) {
this.status = 'shipped';
this.trackingNumber = event.trackingNumber;
}
// ...
}
}
// Read model: uses events to build denormalized views
const eventStore = new EventStore(database);
const events = eventStore.getEvents(orderId);
const order = Order.fromEvents(events);
// Projectors subscribe to event stream and update read models
eventBus.subscribe(event => {
orderListProjector.handle(event);
orderDetailProjector.handle(event);
analyticsProjector.handle(event);
});Event sourcing with CQRS gives you:
- Complete audit trail (every change is recorded)
- Temporal queries (what was this order's state at time T?)
- Easy read model rebuilding (replay events to regenerate)
- Multiple read models from a single event stream
When CQRS Makes Sense
Complex Read Patterns
When clients need data in shapes that don't match your write model.
// Write model: Order with line items (normalized)
class Order {
id;
customerId;
status;
items: OrderLineItem[];
}
class OrderLineItem {
productId;
quantity;
price;
}
// But clients need denormalized read model:
// "Show me all orders with product name, customer name, and total"
// This requires joins across Order, Customer, and Product
// CQRS lets you denormalize this into a single tableHigh Read Volume
When you have millions of reads but thousands of writes, CQRS lets you scale them independently.
Temporal Queries
When you need historical data: "What orders did we place in January?" "How many customers registered last quarter?" CQRS with event sourcing gives you this naturally.
Event-Driven Architecture
When other systems need to react to changes, CQRS naturally produces events that drive other subsystems.
When CQRS Is Overkill
Simple CRUD Applications
A blog. A todo app. A simple internal tool. CRUD is fine. CQRS adds complexity without benefit.
Write-Heavy Systems
If you write more than you read, CQRS provides little advantage. Optimize for writes instead.
Single Read Pattern
If clients only need one shape of data, CQRS adds overhead without benefit. Use traditional databases.
// This doesn't benefit from CQRS
class SimpleTask {
id;
title;
completed;
dueDate;
}
// Everyone queries the same shape
// Traditional database is cleanerThe Real Costs of CQRS
Complexity
You're managing multiple models, synchronization between them, event handling. This is hard to get right. Your team needs to understand event-driven architecture, eventual consistency, and distributed systems.
Eventual Consistency
Read models lag behind write models. "I just created an order but don't see it in the list yet." This confuses users unless you handle it carefully.
// Client creates order
placeOrderCommand.execute();
// Immediately queries read model
// Might not see it yet (eventual consistency)
const orders = getOrderListQuery.execute();
// Solution: return order ID from write operation
// Client can query specific order immediately
const orderId = placeOrderCommand.execute();
const order = getOrderDetailQuery.execute(orderId);
// This order is in the write model, readable immediatelySynchronization Bugs
If read models and write models get out of sync, fixing it is complex. You need careful event handling, idempotency, and possibly event replay.
// If projector crashes mid-way through processing events:
// Read model is partially updated
// Needs recovery mechanism
class RobustProjector {
handle(event) {
try {
// Update read model
this.updateReadModel(event);
// Mark event as processed
this.recordProcessedEvent(event.id);
} catch (error) {
// Don't mark as processed
// Retry will happen
throw error;
}
}
}Development Friction
Building both write and read paths for every feature takes longer. Two models to design, test, and maintain.
FAQ
Isn't CQRS just event sourcing?
No. CQRS is separating read and write models. Event sourcing is storing events instead of state. You can use CQRS without event sourcing, or event sourcing without CQRS. They're orthogonal.
How do I handle consistency?
CQRS assumes eventual consistency for reads. The write model is consistent; reads eventually catch up. If you need immediate consistency, CQRS isn't the right pattern.
Do I need CQRS for microservices?
No. Microservices can use simple CRUD within each service. Use CQRS when you have complex read patterns and high read volume, regardless of your architecture.
Can I use CQRS with a relational database?
Yes. Use one table for writes (normalized), another for reads (denormalized). Triggers or application code keeps them in sync.
What if a client needs multiple read models?
Build multiple read models from the same event stream. That's the point of CQRS.
Primary Sources
- Greg Young's comprehensive overview of Command Query Responsibility Segregation principles. CQRS Documents
- Martin Fowler's explanation of event sourcing and its relationship to CQRS pattern. Event Sourcing
- Foundational text on ubiquitous language, domain models, and command-query separation. Domain-Driven Design
- Practical patterns for implementing domain-driven design in enterprise systems. Implementing DDD
- Patterns for organizing application architecture and managing code complexity. EAA Patterns
- Techniques for safely evolving and refactoring existing system designs. Refactoring
More in this hub
CQRS: Separating Reads from Writes
4 / 10Previous
Article 3
Bounded Contexts: Drawing Lines That Matter
Next
Article 5
Event Sourcing: State from History
Also in this hub
Get Started with Bitloops.
Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.
curl -sSL https://bitloops.com/install.sh | bash