CQRS with AWS Lambda, EventBridge, and DynamoDB
A practical CQRS implementation with AWS Lambda, EventBridge, and DynamoDB, covering event sourcing, eventual consistency, and distributed debugging.
CQRS (Command Query Responsibility Segregation) separates write operations (commands) from read operations (queries). Instead of one model serving both, each side gets a shape that fits its job: the write side validates and stores, the read side answers questions quickly.
Reach for the split when the two workloads have genuinely different shapes: a read-to-write ratio of 10:1 or higher, different latency budgets, or query patterns that a write-optimized schema cannot serve. The setup below uses one DynamoDB table per side, a separate Lambda function per path, and EventBridge carrying the events that keep the read model current. When reads and writes look alike, the pattern only adds moving parts.
The Core Principle
In traditional architectures, you typically use the same data model for both reading and writing:
// Traditional approach - same model for everything
class OrderService {
async createOrder(orderData) {
// Write to the same table
return await db.orders.insert(orderData);
}
async getOrderHistory(customerId) {
// Read from the same table with complex joins
return await db.orders.find()
.join('customers')
.join('products')
.where('customerId', customerId);
}
}
With CQRS, you split this into two optimized models:
// CQRS approach - separate optimized models
class OrderCommandService {
async createOrder(orderData) {
// Write-optimized: Simple, fast inserts
await writeDb.orders.insert(orderData);
// Publish event for read model updates
await eventBus.publish('OrderCreated', orderData);
}
}
class OrderQueryService {
async getOrderHistory(customerId) {
// Read-optimized: Pre-computed, denormalized data
return await readDb.customerOrderHistory.find(customerId);
}
}
What CQRS Fixes
Four specific problems push teams toward the split:
- Performance Mismatch: Writes need validation and consistency, reads need speed
- Scale Mismatch: Most systems have 10:1 or 100:1 read-to-write ratios
- Model Complexity: Optimizing for writes makes reads complex, and vice versa
- Team Parallelization: Different teams can work on read and write sides independently
When CQRS Makes Sense
Use CQRS when you have:
- High read-to-write ratios (10:1 or higher)
- Different performance requirements for reads vs writes
- Complex reporting or analytics needs
- Need to scale reads and writes independently
- Multiple data representation needs (APIs, reports, dashboards)
Avoid CQRS when you have:
- Simple CRUD applications
- Low traffic applications
- Strong consistency requirements everywhere
- Small team that can’t handle the complexity
- Similar read and write patterns
The Problem That Leads to CQRS
A concrete example shows why CQRS becomes necessary. A monolithic Lambda function handles everything: product catalog reads, order processing, inventory updates. During a flash sale, several issues emerge:
- DynamoDB throttling: High write operations from orders competing with read operations from browsing users
- Lambda timeouts: Complex aggregation queries taking significant time
- Cost challenges: Provisioned capacity needed only during peak periods
- Data inconsistency: Inventory counts affected by concurrent updates
Product detail pages carried the majority of that traffic, and they were slow for a structural reason: they read from a data model tuned for order processing.
Architecture Evolution
Before CQRS (The Monolith):
// Single Lambda handling everything - seemed simple at first
export const handler = async (event: APIGatewayEvent) => {
const { httpMethod, path } = event;
if (httpMethod === 'GET' && path === '/products') {
// Complex query joining 3 tables
const products = await dynamoClient.query({
TableName: 'MainTable',
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :pk',
ExpressionAttributeValues: { ':pk': 'PRODUCT' }
}).promise();
// Then fetch inventory for each product (N+1 query problem)
for (const product of products.Items) {
const inventory = await getInventory(product.id);
product.availableQuantity = inventory.quantity;
}
return { statusCode: 200, body: JSON.stringify(products) };
}
if (httpMethod === 'POST' && path === '/orders') {
// Write to same table, competing for throughput
await createOrder(JSON.parse(event.body));
}
};
After CQRS (Separated Concerns):
The Command Side: Handling Writes
// commands/create-order.ts - Focused solely on order processing
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
import { z } from 'zod';
import { ulid } from 'ulid';
const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const eventBridge = new EventBridgeClient({});
// Input validation with Zod at the command boundary
const CreateOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().positive()
})).min(1),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
country: z.string(),
postalCode: z.string()
})
});
export const handler = async (event: any) => {
// Parse and validate input
const input = CreateOrderSchema.parse(JSON.parse(event.body));
const orderId = ulid(); // Time-sortable IDs keep log correlation simple
const timestamp = Date.now();
// Write to command store (write-optimized table)
const order = {
PK: `ORDER#${orderId}`,
SK: `ORDER#${orderId}`,
id: orderId,
customerId: input.customerId,
items: input.items,
total: input.items.reduce((sum, item) => sum + (item.price * item.quantity), 0),
status: 'PENDING',
createdAt: timestamp,
updatedAt: timestamp,
version: 1 // Optimistic locking prevents race conditions
};
try {
await dynamoClient.send(new PutCommand({
TableName: process.env.WRITE_TABLE_NAME!,
Item: order,
ConditionExpression: 'attribute_not_exists(PK)' // Prevent duplicates
}));
// Publish event for read model updates
await eventBridge.send(new PutEventsCommand({
Entries: [{
Source: 'orders.service',
DetailType: 'OrderCreated',
Detail: JSON.stringify({
orderId,
customerId: input.customerId,
items: input.items,
total: order.total,
timestamp
}),
EventBusName: process.env.EVENT_BUS_NAME
}]
}));
return {
statusCode: 201,
body: JSON.stringify({ orderId, status: 'CREATED' })
};
} catch (error) {
console.error('Order creation failed:', error);
// Implement proper error handling and compensation
throw error;
}
};
The Query Side: Optimized Reads
// queries/get-product-catalog.ts - Read-optimized for performance
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
// Pre-computed, denormalized data for fast reads
export const handler = async (event: any) => {
const { category, limit = 20, lastKey } = event.queryStringParameters || {};
// Read from read-optimized table with pre-computed aggregations
const response = await dynamoClient.send(new QueryCommand({
TableName: process.env.READ_TABLE_NAME!,
IndexName: 'CategoryIndex',
KeyConditionExpression: 'category = :category',
ExpressionAttributeValues: {
':category': category || 'ALL'
},
Limit: Number(limit),
ExclusiveStartKey: lastKey ? JSON.parse(Buffer.from(lastKey, 'base64').toString()) : undefined,
// Only fetch what we need for listing
ProjectionExpression: 'id, #n, price, imageUrl, averageRating, reviewCount, inStock',
ExpressionAttributeNames: {
'#n': 'name' // 'name' is a reserved word in DynamoDB
}
}));
return {
statusCode: 200,
headers: {
'Cache-Control': 'public, max-age=300', // 5-minute cache for product listings
},
body: JSON.stringify({
products: response.Items,
nextKey: response.LastEvaluatedKey
? Buffer.from(JSON.stringify(response.LastEvaluatedKey)).toString('base64')
: null
})
};
};
The Event Processor: Keeping Models in Sync
The synchronization layer is where most CQRS implementations fail:
// processors/sync-read-models.ts - The critical synchronization layer
import { EventBridgeEvent } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const sqsClient = new SQSClient({});
interface OrderCreatedEvent {
orderId: string;
customerId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
total: number;
timestamp: number;
}
export const handler = async (event: EventBridgeEvent<'OrderCreated', OrderCreatedEvent>) => {
const { detail } = event;
// Update multiple read models in parallel
const updatePromises = [];
// 1. Update customer order history (optimized for customer queries)
updatePromises.push(
dynamoClient.send(new UpdateCommand({
TableName: process.env.READ_TABLE_NAME!,
Key: {
PK: `CUSTOMER#${detail.customerId}`,
SK: `ORDER#${detail.timestamp}#${detail.orderId}`
},
UpdateExpression: 'SET orderId = :orderId, total = :total, #items = :items, createdAt = :timestamp',
ExpressionAttributeNames: {
'#items': 'items'
},
ExpressionAttributeValues: {
':orderId': detail.orderId,
':total': detail.total,
':items': detail.items,
':timestamp': detail.timestamp
}
}))
);
// 2. Update product statistics (for popular products, bestsellers, etc.)
for (const item of detail.items) {
updatePromises.push(
dynamoClient.send(new UpdateCommand({
TableName: process.env.READ_TABLE_NAME!,
Key: {
PK: `PRODUCT#${item.productId}`,
SK: 'STATS'
},
UpdateExpression: `
ADD salesCount :quantity, revenue :revenue
SET lastSoldAt = :timestamp
`,
ExpressionAttributeValues: {
':quantity': item.quantity,
':revenue': item.price * item.quantity,
':timestamp': detail.timestamp
}
}))
);
}
// 3. Update daily sales aggregations (for dashboards)
const dateKey = new Date(detail.timestamp).toISOString().split('T')[0];
updatePromises.push(
dynamoClient.send(new UpdateCommand({
TableName: process.env.READ_TABLE_NAME!,
Key: {
PK: `SALES#${dateKey}`,
SK: 'AGGREGATE'
},
UpdateExpression: 'ADD orderCount :one, totalRevenue :total',
ExpressionAttributeValues: {
':one': 1,
':total': detail.total
}
}))
);
try {
await Promise.all(updatePromises);
} catch (error) {
console.error('Failed to update read models:', error);
// Send to DLQ for manual intervention
await sqsClient.send(new SendMessageCommand({
QueueUrl: process.env.DLQ_URL!,
MessageBody: JSON.stringify({
event: 'OrderCreated',
detail,
error: error.message,
timestamp: Date.now()
})
}));
throw error; // Let Lambda retry
}
};
Infrastructure as Code with CDK
Here’s the complete serverless CQRS setup:
// infrastructure/cqrs-stack.ts
import { Stack, StackProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
export class CQRSServerlessStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Write model table - optimized for writes
const writeTable = new dynamodb.Table(this, 'WriteTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // On-demand: no throttling during spikes
stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, // For change data capture
pointInTimeRecovery: true,
removalPolicy: RemovalPolicy.RETAIN
});
// Read model table - optimized for queries
const readTable = new dynamodb.Table(this, 'ReadTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: RemovalPolicy.RETAIN
});
// Add GSIs for different query patterns
readTable.addGlobalSecondaryIndex({
indexName: 'CategoryIndex',
partitionKey: { name: 'category', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'popularity', type: dynamodb.AttributeType.NUMBER },
projectionType: dynamodb.ProjectionType.ALL
});
readTable.addGlobalSecondaryIndex({
indexName: 'CustomerIndex',
partitionKey: { name: 'customerId', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'createdAt', type: dynamodb.AttributeType.NUMBER },
projectionType: dynamodb.ProjectionType.ALL
});
// Event bus for CQRS events
const eventBus = new events.EventBus(this, 'CQRSEventBus', {
eventBusName: 'cqrs-events'
});
// Dead letter queue for failed events
const dlq = new sqs.Queue(this, 'EventDLQ', {
queueName: 'cqrs-event-dlq',
retentionPeriod: Duration.days(14)
});
// Command handlers
const createOrderHandler = new lambda.NodejsFunction(this, 'CreateOrderHandler', {
entry: 'src/commands/create-order.ts',
runtime: Runtime.NODEJS_22_X,
memorySize: 1024,
timeout: Duration.seconds(10),
environment: {
WRITE_TABLE_NAME: writeTable.tableName,
EVENT_BUS_NAME: eventBus.eventBusName,
AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1'
},
bundling: {
minify: true,
target: 'node22',
externalModules: ['@aws-sdk/*']
}
});
writeTable.grantWriteData(createOrderHandler);
eventBus.grantPutEventsTo(createOrderHandler);
// Query handlers
const getProductsHandler = new lambda.NodejsFunction(this, 'GetProductsHandler', {
entry: 'src/queries/get-product-catalog.ts',
runtime: Runtime.NODEJS_22_X,
memorySize: 512, // Read-only, needs less memory
timeout: Duration.seconds(5),
environment: {
READ_TABLE_NAME: readTable.tableName,
AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1'
}
});
readTable.grantReadData(getProductsHandler);
// Event processor for syncing read models
const syncProcessor = new lambda.NodejsFunction(this, 'SyncProcessor', {
entry: 'src/processors/sync-read-models.ts',
runtime: Runtime.NODEJS_22_X,
memorySize: 2048, // Handles batch updates
timeout: Duration.seconds(30),
reservedConcurrentExecutions: 10, // Prevent overwhelming downstream services
environment: {
READ_TABLE_NAME: readTable.tableName,
DLQ_URL: dlq.queueUrl,
AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1'
},
deadLetterQueue: dlq,
retryAttempts: 2
});
readTable.grantWriteData(syncProcessor);
dlq.grantSendMessages(syncProcessor);
// Event rules
new events.Rule(this, 'OrderCreatedRule', {
eventBus,
eventPattern: {
source: ['orders.service'],
detailType: ['OrderCreated']
},
targets: [new targets.LambdaFunction(syncProcessor, {
retryAttempts: 2,
maxEventAge: Duration.hours(2)
})]
});
// API Gateway
const api = new apigateway.RestApi(this, 'CQRSAPI', {
restApiName: 'cqrs-api',
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS
}
});
// Command endpoints
const orders = api.root.addResource('orders');
orders.addMethod('POST', new apigateway.LambdaIntegration(createOrderHandler));
// Query endpoints
const products = api.root.addResource('products');
products.addMethod('GET', new apigateway.LambdaIntegration(getProductsHandler));
}
}
Handling Eventual Consistency
CQRS means accepting eventual consistency. Here’s how to handle it without confusing users:
// strategies/consistency-handling.ts
export class ConsistencyStrategy {
// Strategy 1: Optimistic UI updates
async createOrderWithOptimisticUpdate(orderData: any) {
// Immediately show success to user
const tempOrderId = `temp_${Date.now()}`;
updateUI({ orderId: tempOrderId, status: 'processing' });
try {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(orderData)
});
const { orderId } = await response.json();
// Replace temp ID with real ID
updateUI({ oldId: tempOrderId, newId: orderId, status: 'confirmed' });
// Poll for read model update
await this.waitForReadModelSync(orderId);
} catch (error) {
// Rollback optimistic update
removeFromUI(tempOrderId);
showError('Order failed');
}
}
// Strategy 2: Polling with exponential backoff
async waitForReadModelSync(orderId: string, maxAttempts = 5) {
let attempts = 0;
let delay = 100; // Start with 100ms
while (attempts < maxAttempts) {
const order = await this.checkReadModel(orderId);
if (order) {
return order;
}
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
attempts++;
}
// Fall back to command model query
return this.queryCommandModel(orderId);
}
// Strategy 3: WebSocket notifications
subscribeToOrderUpdates(customerId: string) {
const ws = new WebSocket(`wss://api.example.com/orders/${customerId}`);
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
if (update.type === 'READ_MODEL_SYNCED') {
refreshOrderList();
}
};
}
}
Testing CQRS in Serverless
Testing distributed systems is hard. Here’s a practical approach:
// tests/cqrs-integration.test.ts
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { SQSClient } from '@aws-sdk/client-sqs';
import { mockClient } from 'aws-sdk-client-mock';
import { handler } from '../src/commands/create-order';
import { handler as syncProcessor } from '../src/processors/sync-read-models';
describe('CQRS Event Flow', () => {
const eventBridgeMock = mockClient(EventBridgeClient);
const dynamoMock = mockClient(DynamoDBDocumentClient);
const sqsMock = mockClient(SQSClient);
beforeEach(() => {
eventBridgeMock.reset();
dynamoMock.reset();
sqsMock.reset();
});
test('Order creation triggers read model update', async () => {
// Arrange
const orderId = 'test-order-123';
eventBridgeMock.on(PutEventsCommand).resolves({
FailedEntryCount: 0,
Entries: [{ EventId: 'event-123' }]
});
// Act - Create order
const response = await handler({
body: JSON.stringify({
customerId: 'customer-123',
items: [{ productId: 'prod-1', quantity: 2, price: 99.99 }]
})
});
// Assert - Event was published
expect(eventBridgeMock.calls()).toHaveLength(1);
const eventCall = eventBridgeMock.call(0);
expect(eventCall.args[0].input.Entries[0].DetailType).toBe('OrderCreated');
// Simulate event processor
await syncProcessor({
detail: JSON.parse(eventCall.args[0].input.Entries[0].Detail)
});
// Assert - Read models updated
const readModelCalls = dynamoMock.calls().filter(
call => call.args[0].input.TableName === 'ReadTable'
);
expect(readModelCalls).toHaveLength(3); // Customer, Product, Daily stats
});
test('Failed event processing sends to DLQ', async () => {
// Simulate DynamoDB failure
dynamoMock.on(UpdateCommand).rejects(new Error('Throttled'));
const event = {
detail: {
orderId: 'order-123',
customerId: 'customer-123',
items: [],
total: 100,
timestamp: Date.now()
}
};
await expect(syncProcessor(event)).rejects.toThrow('Throttled');
// Verify DLQ message
const sqsCalls = sqsMock.calls();
expect(sqsCalls).toHaveLength(1);
expect(JSON.parse(sqsCalls[0].args[0].input.MessageBody))
.toHaveProperty('error', 'Throttled');
});
});
Monitoring and Debugging CQRS
The distributed nature of CQRS makes debugging challenging. Here’s an effective monitoring setup:
// monitoring/cqrs-metrics.ts
import { MetricUnit, Metrics } from '@aws-lambda-powertools/metrics';
import { Tracer } from '@aws-lambda-powertools/tracer';
import { Logger } from '@aws-lambda-powertools/logger';
const metrics = new Metrics({ namespace: 'CQRS', serviceName: 'orders' });
const tracer = new Tracer({ serviceName: 'orders' });
const logger = new Logger({ serviceName: 'orders' });
export const instrumentedHandler = tracer.captureLambdaHandler(
metrics.logMetrics(
async (event: any) => {
const segment = tracer.getSegment();
// Track command/query separation
const operationType = event.httpMethod === 'GET' ? 'QUERY' : 'COMMAND';
metrics.addMetric(`${operationType}_REQUEST`, MetricUnit.Count, 1);
const startTime = Date.now();
try {
// Add correlation ID for tracing across services
const correlationId = event.headers['x-correlation-id'] || ulid();
segment?.addAnnotation('correlationId', correlationId);
logger.appendKeys({ correlationId });
// Track read/write model sync lag
if (operationType === 'QUERY') {
const syncLag = await measureSyncLag();
metrics.addMetric('READ_MODEL_LAG_MS', MetricUnit.Milliseconds, syncLag);
if (syncLag > 5000) {
logger.warn('High read model lag detected', { syncLag });
}
}
const result = await processRequest(event);
metrics.addMetric(`${operationType}_SUCCESS`, MetricUnit.Count, 1);
metrics.addMetric(`${operationType}_DURATION`, MetricUnit.Milliseconds,
Date.now() - startTime);
return result;
} catch (error) {
metrics.addMetric(`${operationType}_ERROR`, MetricUnit.Count, 1);
logger.error('Request failed', { error, event });
throw error;
}
}
)
);
// Custom CloudWatch dashboard
export const dashboardConfig = {
widgets: [
{
type: 'metric',
properties: {
metrics: [
['CQRS', 'COMMAND_REQUEST', { stat: 'Sum' }],
['.', 'QUERY_REQUEST', { stat: 'Sum' }],
['.', 'READ_MODEL_LAG_MS', { stat: 'Average' }]
],
period: 300,
stat: 'Average',
region: 'us-east-1',
title: 'CQRS Operations'
}
}
]
};
Where the Cost Changes
Splitting the models changes the shape of the bill more than the amount of work. Four effects account for most of the difference:
- No over-provisioning for peak loads: on-demand billing on both tables removes the peak-capacity premium, usually the largest line item in a provisioned setup
- Cached read models absorb repeat traffic before it reaches the database
- Focused functions need less memory than one handler sized for its heaviest branch
- Purpose-built indexes replace scans and N+1 lookups on the read path
The counterweight is that the write path now pays twice: once for the command table, once for the projection into the read table. That trade is worth it when reads dominate and it is not worth it when they do not. Price both paths against your own traffic mix before committing.
Lessons Learned
1. Start With One Read Model
Early implementations tend to create too many read models. Begin with one and add another only when a query pattern demands it. Every extra model is extra sync logic that has to stay correct.
2. Event Versioning is Critical
Skipping event versioning initially is a common mistake: adding a field to OrderCreated breaks every consumer. The correct approach:
interface OrderCreatedV1 {
version: 1;
orderId: string;
customerId: string;
total: number;
}
interface OrderCreatedV2 {
version: 2;
orderId: string;
customerId: string;
total: number;
currency: string; // New field
}
// Handler supports both versions
export const handler = async (event: OrderCreatedV1 | OrderCreatedV2) => {
const currency = 'version' in event && event.version >= 2
? (event as OrderCreatedV2).currency
: 'USD'; // Default for V1
};
3. Idempotency Everywhere
Events can be delivered multiple times. Every handler must be idempotent:
// Use conditional writes to ensure idempotency
await dynamoClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: processedEvent,
ConditionExpression: 'attribute_not_exists(eventId)'
}));
4. Monitor the Sync Lag
The time between command execution and read model update is the most important metric. Alert if it exceeds 5 seconds.
5. Plan for Reconciliation
Read models will drift. A nightly job that compares command and query models fixes discrepancies:
// Runs during low-traffic periods daily
export const reconciliationJob = async () => {
const commandRecords = await scanCommandTable();
const readRecords = await scanReadTable();
const discrepancies = findDiscrepancies(commandRecords, readRecords);
for (const issue of discrepancies) {
await republishEvent(issue.originalEvent);
logger.warn('Reconciliation required', { issue });
}
metrics.addMetric('RECONCILIATION_FIXES', MetricUnit.Count, discrepancies.length);
};
When NOT to Use CQRS
CQRS adds complexity. It pays off in the right context, but avoid it if:
- Your read/write patterns are similar
- You have simple CRUD operations
- Strong consistency is required everywhere
- Your team isn’t comfortable with eventual consistency
- You’re not experiencing performance issues
CQRS on an internal admin panel with plain CRUD screens is a failure mode: complexity with no payoff.
The Silent Event Processor Failure
The failure that hurts most is an event processor that stops working quietly. The command side keeps accepting writes, the read model stops advancing, and customers see stale order statuses long before anyone notices. A Lambda timeout on one slow event category, plus a dead letter queue nobody watches, produces exactly that.
Four configuration details prevent it:
- Configure the DLQ with a CloudWatch alarm on message arrival, and a visibility timeout longer than the processing retry window
- Add exponential backoff and a circuit breaker to the event processor
- Run read-after-write consistency checks on user-facing operations that cannot tolerate lag
- Keep a “fall back to the command model” path for when the read model is behind
The Limits of the Default
Separate tables, separate functions, EventBridge in between: that shape holds when the read side is busier than the write side and asks different questions of the same data. Start from a single model, measure where it hurts, and split only the path that hurts. One read model is enough to begin with, and the second should arrive because a query needs it, not because the diagram looks symmetric.
Override the default when strong consistency is a product requirement rather than a preference. A user who must see their own write immediately does not care that the read model catches up a moment later. Serve that one path from the command model, keep CQRS for the rest, and eventual consistency stops being a user-visible problem.
References
- CQRS - Martin Fowler - Authoritative introduction to Command Query Responsibility Segregation, its trade-offs, and when not to apply it
- CQRS Pattern - Azure Architecture Center - Implementation guide covering separate read/write models, eventual consistency, and integration with event sourcing
- Event Sourcing Pattern - Azure Architecture Center - Complementary pattern for storing state as an immutable event log, commonly paired with CQRS
- CQRS Pattern - microservices.io - Chris Richardson’s practical guide to applying CQRS in a microservices context with view database trade-offs
- What is Amazon DynamoDB? - AWS official introduction to DynamoDB’s data model, capacity modes, and consistency options relevant to CQRS read models
Related posts
Build a testing strategy for AWS Lambda, API Gateway, DynamoDB, and Step Functions with practical patterns for fast feedback and reliability.
Set up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda: architecture decisions, project layout, and the schema choices that hold up at scale.
Hold AWS Lambda warm-path latency inside a 10 ms budget with runtime choice, connection reuse, bundle discipline, caching, and memory tuning.
A practical guide to implementing customer relationship management using event sourcing, CQRS, and event-driven patterns for marketing automation and consent management
Multi-environment deployment, performance optimization at scale, cost management, and monitoring with solid incident response patterns.