CQRS—Command Query Responsibility Segregation—sounds intimidating. Most articles describe it as this architectural overhaul requiring separate databases, event stores, and distributed systems. Over my 5+ years building production platforms, I've learned that CQRS without the complexity is actually simpler than people think: separate how you handle writes from how you handle reads, keep it pragmatic, and scale only what needs scaling.
You don't need event sourcing. You don't need Kafka. You don't even need two databases. What you need is clarity: commands change state and validate business rules, queries fetch optimized views. That's it. Let's walk through it.
Commands — Validate business rules and change state
A command is an explicit request to do something. Place an order. Create a user. Update inventory. Commands are imperative; they express intent.
The key responsibility of a command handler is validation and state change. Before you modify anything, you validate business rules. If the order total exceeds credit limit, reject it. If the user already exists with that email, fail early. Only after passing all checks do you update the database.
Here's a practical example from an e-commerce platform I built:
interface PlaceOrderCommand {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
shippingAddress: string;
}
class PlaceOrderHandler {
constructor(private db: Database, private emailService: EmailService) {}
async handle(command: PlaceOrderCommand): Promise<string> {
// Validate: customer exists
const customer = await this.db.customers.findById(command.customerId);
if (!customer) {
throw new Error('Customer not found');
}
// Validate: items in stock
for (const item of command.items) {
const product = await this.db.products.findById(item.productId);
if (!product || product.stock < item.quantity) {
throw new Error(`Insufficient stock for ${item.productId}`);
}
}
// Validate: total within credit limit
const total = command.items.reduce((sum, item) => {
const product = await this.db.products.findById(item.productId);
return sum + (product.price * item.quantity);
}, 0);
if (total > customer.creditLimit) {
throw new Error('Order exceeds credit limit');
}
// State change: insert order, update stock, send confirmation
const orderId = await this.db.orders.create({
customerId: command.customerId,
items: command.items,
total,
shippingAddress: command.shippingAddress,
status: 'confirmed',
createdAt: new Date(),
});
for (const item of command.items) {
await this.db.products.decrementStock(item.productId, item.quantity);
}
await this.emailService.sendOrderConfirmation(customer.email, orderId);
return orderId;
}
}
Notice the flow: validate → validate → validate → change state. Commands are synchronous and fail fast. There's no ambiguity about whether the order was placed. If the handler returns, the order exists in the database.
The mistake many make is mixing query logic into command handlers. "Let me fetch the customer, check their order history, validate against some complex rule, then update." That's fine, but keep the validation and state-change concerns central. Everything else—formatting, pagination, filtering—belongs elsewhere.
Queries — Read optimized views, often projections
A query fetches data. It doesn't change anything. Unlike commands, queries have no side effects.
Queries read from optimized views. A view might be a denormalized table, a materialized result, or a cache. The idea is simple: structure your read data to serve the use case, not to mirror the normalized write schema.
On a ride-hailing platform I worked on, the write model stored trips with normalized references:
interface Trip {
id: string;
driverId: string;
passengerId: string;
startLocationId: string;
endLocationId: string;
createdAt: Date;
status: 'requested' | 'accepted' | 'in_progress' | 'completed';
}
interface Driver {
id: string;
name: string;
rating: number;
vehicle: string;
}
But when the app displays the trip history screen, the passenger needs everything at once: trip status, driver name, driver rating, vehicle, pickup address, dropoff address, cost. Fetching this from normalized tables requires multiple joins.
Instead, we created a denormalized view:
interface TripHistoryView {
tripId: string;
status: string;
driverName: string;
driverRating: number;
vehicle: string;
pickupAddress: string;
dropoffAddress: string;
costAmount: number;
createdAt: Date;
}
class TripHistoryQueryHandler {
constructor(private db: Database) {}
async getPassengerTrips(passengerId: string): Promise<TripHistoryView[]> {
// Single query, no joins
return this.db.tripHistoryViews
.where({ passengerId })
.orderBy('createdAt', 'desc')
.limit(50);
}
}
How does this view stay in sync? When a trip command completes—passenger requests, driver accepts, trip finishes—you update both the transactional tables and the view. A simple approach:
async completeTrip(tripId: string, costAmount: number): Promise<void> {
// Update write model
await this.db.trips.update(tripId, { status: 'completed' });
// Update read model (denormalized view)
const trip = await this.db.trips.findById(tripId);
const driver = await this.db.drivers.findById(trip.driverId);
const locations = await this.db.locations.findByIds([
trip.startLocationId,
trip.endLocationId,
]);
await this.db.tripHistoryViews.update(tripId, {
status: 'completed',
driverName: driver.name,
driverRating: driver.rating,
vehicle: driver.vehicle,
pickupAddress: locations[0].address,
dropoffAddress: locations[1].address,
costAmount,
});
}
This is CQRS in its simplest form: write commands update tables and denormalized views; read queries fetch from the denormalized views.
Same database is fine — CQRS doesn't require separate databases
Many articles suggest CQRS demands a separate read database. Event sourcing, materialized projections in a different store, replication lag to handle. It sounds advanced and justified if you're building Google-scale search infrastructure.
Most platforms don't need it. A single PostgreSQL database with a normalized write schema and denormalized read views works perfectly. You control consistency yourself—update both in the same transaction, and everything is in sync.
Here's what I've found works at scale across e-commerce, CRM, and trading platforms:
- Small to medium load: Write to normalized tables, write to denormalized views, both in one transaction. Consistency guaranteed.
- High read volume: Add Redis cache on top of the views. Invalidate cache when commands fire. Same database, better performance.
- Very high load or complex domains: Consider event sourcing, but still in the same database initially. PostgreSQL handles append-only event logs fine.
The benefit of one database is operational simplicity. No replication lag. No eventual consistency headaches. No extra infrastructure. You separate concerns (commands and queries) without separating infrastructure.
Use where reads and writes differ — Dashboards and complex domains benefit most
CQRS isn't for every feature. It's most valuable when read and write patterns are fundamentally different.
Dashboards are a classic case. You might write a single order per transaction, but you read the dashboard—with filtering, sorting, aggregations, charts—hundreds of times. A dashboard query shouldn't do expensive calculations; it should fetch a precomputed view.
On a SaaS analytics platform, the write side recorded raw events:
interface Event {
id: string;
userId: string;
eventType: string;
timestamp: Date;
metadata: Record<string, any>;
}
The read side? Dozens of queries: "How many signups today?" "Which features are most used?" "Conversion rate by cohort?" Each query aggregates raw events in different ways.
Without CQRS, each query scans millions of rows, groups, counts, joins. With CQRS, you maintain pre-aggregated views:
interface DailyMetrics {
date: string;
totalSignups: number;
totalActiveUsers: number;
featureUsage: Record<string, number>;
conversionRate: number;
}
// Updated nightly or incrementally
const metrics = await this.db.dailyMetrics.findByDate('2025-01-10');
Complex domains also benefit. In a trading platform, the write model handles order placement with strict validations. The read model serves multiple views: portfolio overview, transaction history, risk analytics. Each requires different aggregations and filters. Separating them keeps both simple.
Conversely, simple CRUD features don't need CQRS. A basic user profile—read it, update it—works fine with a single model.
Common mistakes
Overcomplicating it too early. Developers add event sourcing, separate databases, message queues, before there's a need. Start with commands and denormalized views in one database. Add complexity only when you measure a problem.
Denormalization without clear intent. Create denormalized views for specific queries, not for everything. Maintain only views that save meaningful computation or improve response time. Otherwise, you're just duplicating data without benefit.
Forgetting to keep views in sync. If you update the write model but forget the view, they diverge. Cache inconsistencies appear in production. Use transactions to update both, or use an event listener (even a simple trigger or message queue). Be explicit about synchronization.
Treating commands and queries the same. Commands should be small, focused, and imperative. Queries should be optimized for reading. Mixing concerns—adding complex filtering logic to a command, or business validation to a query—creates confusion.
Key takeaways
- CQRS is about separating responsibility: commands validate and change state, queries read optimized views.
- You don't need separate databases. One database with normalized write schema and denormalized read views is simple and scales well for most platforms.
- Denormalized views are projections—different shapes of the same data, optimized for specific queries. Update them alongside writes, in the same transaction.
- CQRS shines when read and write patterns differ significantly: dashboards, analytics, complex domains. Simple CRUD doesn't need it.
- Start pragmatically. Add denormalized views when queries get slow. Add caching when reads spike. Add event sourcing only if you need audit trails or rebuilding views. Each step is independent and justified by measured problems.






Comments
Loading comments…