Architecture
CQRS Pattern
Separate read and write operations
Advanced
architecture patterns data ddd
Definition
CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates read operations (queries) from write operations (commands). Instead of using the same model for both, CQRS uses separate models optimized for each purpose, enabling better performance, scalability, and security.
Traditional vs CQRS
Traditional CRUD
// Same model for everything
class UserService {
// Read
async getUser(id) {
return await db.users.findById(id);
}
// Write
async updateUser(id, data) {
return await db.users.update(id, data);
}
// List with complex query
async searchUsers(filters) {
return await db.users.find({
where: buildComplexWhere(filters),
include: ['profile', 'posts', 'comments']
});
}
}
CQRS Approach
// Write Model (Commands)
class UserCommandService {
async createUser(command) {
const user = User.create(command);
await this.userRepository.save(user);
await this.eventBus.publish(new UserCreated(user.id));
return user.id;
}
async updateEmail(command) {
const user = await this.userRepository.findById(command.userId);
user.changeEmail(command.email);
await this.userRepository.save(user);
}
}
// Read Model (Queries)
class UserQueryService {
// Optimized for specific queries
async getUserById(id) {
return await this.readDb.users.findById(id);
}
async searchUsers(criteria) {
// Denormalized, indexed for reads
return await this.readDb.userSearchView.find({
$text: { $search: criteria.query }
});
}
async getUserDashboard(userId) {
// Pre-computed view
return await this.readDb.userDashboards.findOne({ userId });
}
}
When to Use CQRS
Good Candidates
✓ Complex domains with different read/write needs
✓ High-read, low-write scenarios
✓ Need for optimized query performance
✓ Different teams working on reads vs writes
✓ Event sourcing integration
✓ Multi-model requirements
Avoid When
✗ Simple CRUD applications
✗ Small teams, simple domains
✗ Tight coupling between reads and writes needed
✗ Eventual consistency is unacceptable
Implementation Patterns
Single Database
// Same DB, different models
// Simplest approach, good for starting
// Command side
class OrderCommandHandler {
async handle(command) {
const order = await this.orderRepo.findById(command.orderId);
order.confirm();
await this.orderRepo.save(order);
}
}
// Query side - use projections
class OrderQueryHandler {
async getOrderSummary(orderId) {
// Project only needed fields
return await db.query(`
SELECT o.id, o.status, c.name as customerName,
SUM(oi.price * oi.quantity) as total
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.id = oi.order_id
WHERE o.id = ?
GROUP BY o.id, o.status, c.name
`, [orderId]);
}
}
Separate Read/Write Databases
// Write to OLTP (MySQL, PostgreSQL)
// Read from OLAP (Elasticsearch, MongoDB)
// Command writes to primary
class CreateOrderHandler {
async handle(command) {
const order = Order.create(command);
await this.writeDb.orders.insert(order);
// Publish event for read model sync
await this.eventBus.publish(new OrderCreated(order));
}
}
// Event handler updates read model
class OrderProjectionHandler {
async handleOrderCreated(event) {
await this.readDb.orderViews.insert({
orderId: event.orderId,
customerName: event.customerName,
total: event.total,
status: 'pending',
createdAt: event.createdAt
});
}
async handleOrderConfirmed(event) {
await this.readDb.orderViews.update(
{ orderId: event.orderId },
{ status: 'confirmed', confirmedAt: event.confirmedAt }
);
}
}
Frontend CQRS
State Management
// Redux Toolkit with separate slices
// Write Slice (Commands)
const cartCommandSlice = createSlice({
name: 'cartCommands',
initialState: {},
reducers: {
addItemCommand: (state, action) => {
// Just record the command
state.pendingCommands.push({
type: 'ADD_ITEM',
payload: action.payload,
timestamp: Date.now()
});
}
}
});
// Read Slice (Queries/Projections)
const cartQuerySlice = createSlice({
name: 'cartQueries',
initialState: {
items: [],
total: 0,
itemCount: 0
},
reducers: {
cartUpdated: (state, action) => {
// Pre-computed view
state.items = action.payload.items;
state.total = action.payload.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
state.itemCount = action.payload.items.reduce(
(sum, item) => sum + item.quantity, 0
);
}
}
});
API Design
// Separate endpoints for commands and queries
// Commands (mutations)
POST /api/commands/orders/create
POST /api/commands/orders/cancel
POST /api/commands/orders/ship
// Queries (read-only)
GET /api/queries/orders/:id
GET /api/queries/orders/search?q=...
GET /api/queries/orders/dashboard
Benefits
1. Optimized Performance
// Write model: Normalized, transaction-safe
// Read model: Denormalized, indexed for queries
// Read model example - pre-computed aggregations
const orderSummary = {
orderId: '123',
customerName: 'John Doe',
totalAmount: 299.99,
itemCount: 5,
status: 'shipped',
lastUpdated: '2024-01-15T10:30:00Z'
// No need to join tables!
};
2. Scalability
Read Side:
- Scale horizontally (10+ read replicas)
- Cache aggressively
- Use specialized read databases (Elasticsearch)
Write Side:
- Scale vertically (stronger consistency)
- Fewer replicas needed
- Optimized for transactions
3. Security
// Different authorization for reads vs writes
// Command requires admin
class DeleteUserHandler {
async handle(command) {
if (!this.authService.hasRole('admin')) {
throw new UnauthorizedError();
}
// ... delete user
}
}
// Query available to all
class GetUserProfileHandler {
async handle(query) {
// Any authenticated user can read profiles
return await this.userRepo.findPublicProfile(query.userId);
}
}
Challenges
1. Eventual Consistency
// Command completes
await orderService.confirmOrder(orderId);
// Read model not yet updated
const order = await orderQueryService.getOrder(orderId);
console.log(order.status); // Might still be 'pending'
// Solution: Optimistic UI or wait for projection
2. Complexity
- Two models to maintain
- Synchronization logic
- Event handling infrastructure
- More complex testing
3. Data Duplication
// Same data in multiple places
// Order data in:
// - orders table (write)
// - order_views collection (read)
// - search index (read)
// - cache (read)
Best Practices
1. Start Simple
// Begin with single database
// Separate models at application layer
// Only split databases when needed
2. Use Events for Sync
// Event-driven synchronization
class OrderCreatedEvent {
constructor(orderId, customerId, items, total) {
this.orderId = orderId;
this.customerId = customerId;
this.items = items;
this.total = total;
this.createdAt = new Date();
}
}
// Handler updates all read models
async function handleOrderCreated(event) {
await Promise.all([
updateOrderView(event),
updateCustomerDashboard(event),
updateSearchIndex(event),
sendNotification(event)
]);
}
3. Idempotent Handlers
// Handle duplicate events gracefully
async function updateOrderView(event) {
const existing = await readDb.orderViews.findById(event.orderId);
if (existing && existing.version >= event.version) {
return; // Already processed
}
await readDb.orderViews.upsert({
orderId: event.orderId,
...event.data,
version: event.version
});
}
Key Takeaway
CQRS separates read and write operations for optimized performance and scalability. Start with logical separation in a single database before moving to physical separation. Use events to synchronize read models and embrace eventual consistency. CQRS adds complexity—only use it when the domain complexity justifies it.