Claude Code for CQRS Read Model (2026)
Teams adopting cqrs read model quickly discover the difficulty of evaluation metric selection and training data versioning. This walkthrough demonstrates how Claude Code streamlines the cqrs read model workflow from initial setup onward.
CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates read and write operations into distinct models. While the write side handles commands (create, update, delete), the read side provides optimized data representations for querying. This guide shows how Claude Code can streamline your CQRS read model workflow, from initial design to ongoing maintenance, with practical TypeScript examples, projection patterns, synchronization strategies, and schema versioning techniques.
Understanding CQRS Read Models
In CQRS, the read model is a denormalized projection of your data, optimized for specific query patterns. Unlike the write model (which follows normalized database design), read models are tailored to your UI requirements. For example, an e-commerce application might have separate read models for product listings, order history, and dashboard analytics.
The key advantage is performance: each read model serves a specific use case without complex joins or aggregations at query time. However, this flexibility comes with complexity, you need to synchronize data between models and keep them consistent with the write side.
CQRS vs Traditional Architecture
Before reaching for CQRS, it helps to understand where it fits relative to simpler patterns:
| Dimension | Monolithic (Single Model) | CQRS with Separate Read Models |
|---|---|---|
| Query complexity | High (joins everywhere) | Low (pre-aggregated) |
| Write complexity | Low (one schema) | Moderate (projection logic) |
| Read performance | Bounded by write schema | Optimized per query pattern |
| Consistency | Strong | Eventual (configurable) |
| Operational overhead | Low | Moderate to high |
| When to use | Small to medium apps | High-read throughput, complex UI data |
CQRS pays off when your read patterns diverge significantly from your write patterns, for example, when a single command triggers updates to a dozen different views, or when your query volume is orders of magnitude higher than your write volume.
Setting Up Claude Code for CQRS Workflows
Claude Code can accelerate CQRS read model development through its file operations, code generation, and pattern recognition capabilities. Here’s how to set up an efficient workflow:
First, create a project structure that separates your read models by query type:
mkdir -p src/read-models/{products,orders,dashboard}
mkdir -p src/commands
mkdir -p src/projections
mkdir -p src/event-store
mkdir -p src/query-handlers
For Claude Code users, create a skill that encapsulates your CQRS patterns. This skill can generate read model classes, projection configurations, and query handlers based on your domain models.
A minimal but complete directory structure for a Node.js CQRS project looks like this:
src/
commands/
create-order.command.ts
update-order-status.command.ts
events/
order-created.event.ts
order-status-changed.event.ts
read-models/
orders/
order-list.read-model.ts
order-detail.read-model.ts
products/
product-catalog.read-model.ts
dashboard/
sales-summary.read-model.ts
projections/
order.projection.ts
product.projection.ts
query-handlers/
get-order-list.handler.ts
get-order-detail.handler.ts
repositories/
read-model.repository.ts
Claude Code can generate this entire structure from a description of your domain entities and the queries your UI needs to support.
Defining Domain Events
Read model projections react to domain events. Before writing any projection code, define your event types precisely, Claude Code uses these interfaces to generate correct projection handlers:
// events/order-created.event.ts
export interface OrderCreatedEvent {
type: 'ORDER_CREATED';
orderId: string;
customerId: string;
customerName: string;
customerEmail: string;
items: Array<{
productId: string;
productName: string;
quantity: number;
unitPrice: number;
}>;
shippingAddress: {
street: string;
city: string;
country: string;
postalCode: string;
};
timestamp: string; // ISO 8601
correlationId: string;
}
// events/order-status-changed.event.ts
export type OrderStatus =
| 'pending'
| 'confirmed'
| 'processing'
| 'shipped'
| 'delivered'
| 'cancelled';
export interface OrderStatusChangedEvent {
type: 'ORDER_STATUS_CHANGED';
orderId: string;
previousStatus: OrderStatus;
newStatus: OrderStatus;
changedBy: string;
reason?: string;
timestamp: string;
correlationId: string;
}
Detailed event interfaces give Claude Code the information it needs to generate projection methods, test fixtures, and query handler types without you having to re-describe the domain.
Building Read Model Projections
Projections transform domain events into read model updates. When a command modifies the system, events are emitted and caught by projections that update relevant read models. Here’s a practical example:
// projections/order-read-model.ts
interface OrderReadModel {
orderId: string;
customerName: string;
total: number;
status: 'pending' | 'confirmed' | 'shipped' | 'delivered';
items: Array<{ productId: string; quantity: number; price: number }>;
createdAt: Date;
}
class OrderProjection {
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
const readModel: OrderReadModel = {
orderId: event.orderId,
customerName: event.customerName,
total: event.total,
status: 'pending',
items: event.items,
createdAt: event.timestamp,
};
await this.repository.save('orders', event.orderId, readModel);
}
async handleOrderStatusChanged(event: OrderStatusChangedEvent): Promise<void> {
await this.repository.update('orders', event.orderId, {
status: event.newStatus,
});
}
}
Use Claude Code to generate these projection classes by describing your domain events and read model requirements. This reduces boilerplate and ensures consistency across your projection layer.
A Production-Ready Projection Class
The simplified example above omits error handling, idempotency, and logging. Here is a more complete projection that Claude Code can generate for production use:
// projections/order.projection.ts
import { Logger } from '../logger';
import { ReadModelRepository } from '../repositories/read-model.repository';
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events';
export interface OrderListItem {
orderId: string;
customerName: string;
customerEmail: string;
total: number;
itemCount: number;
status: string;
createdAt: string;
updatedAt: string;
}
export interface OrderDetail extends OrderListItem {
items: Array<{
productId: string;
productName: string;
quantity: number;
unitPrice: number;
lineTotal: number;
}>;
shippingAddress: {
street: string;
city: string;
country: string;
postalCode: string;
};
statusHistory: Array<{
status: string;
changedAt: string;
changedBy: string;
reason?: string;
}>;
}
export class OrderProjection {
constructor(
private readonly repository: ReadModelRepository,
private readonly logger: Logger,
) {}
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
const total = event.items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0,
);
const listItem: OrderListItem = {
orderId: event.orderId,
customerName: event.customerName,
customerEmail: event.customerEmail,
total,
itemCount: event.items.length,
status: 'pending',
createdAt: event.timestamp,
updatedAt: event.timestamp,
};
const detail: OrderDetail = {
...listItem,
items: event.items.map(item => ({
...item,
lineTotal: item.quantity * item.unitPrice,
})),
shippingAddress: event.shippingAddress,
statusHistory: [
{
status: 'pending',
changedAt: event.timestamp,
changedBy: 'system',
},
],
};
await Promise.all([
this.repository.upsert('order-list', event.orderId, listItem),
this.repository.upsert('order-detail', event.orderId, detail),
]);
this.logger.info('OrderProjection: applied ORDER_CREATED', {
orderId: event.orderId,
correlationId: event.correlationId,
});
}
async handleOrderStatusChanged(event: OrderStatusChangedEvent): Promise<void> {
const now = event.timestamp;
await Promise.all([
this.repository.patch('order-list', event.orderId, {
status: event.newStatus,
updatedAt: now,
}),
this.repository.arrayPush('order-detail', event.orderId, 'statusHistory', {
status: event.newStatus,
changedAt: now,
changedBy: event.changedBy,
reason: event.reason,
}),
this.repository.patch('order-detail', event.orderId, {
status: event.newStatus,
updatedAt: now,
}),
]);
this.logger.info('OrderProjection: applied ORDER_STATUS_CHANGED', {
orderId: event.orderId,
newStatus: event.newStatus,
correlationId: event.correlationId,
});
}
}
Notice that the projection maintains two separate read models (order-list and order-detail) from the same events. This is intentional: the list view needs a lightweight representation for pagination while the detail view needs the full order history and line items.
Optimizing Read Model Queries
Each read model should be optimized for its specific query pattern. Here are practical optimization strategies:
Denormalization for Query Performance Embed related data directly in your read model rather than using joins:
// Instead of referencing customerId, embed customer details
interface ProductReadModel {
productId: string;
name: string;
price: number;
categoryName: string; // Denormalized from category lookup
categorySlug: string; // For URL construction
averageRating: number; // Pre-calculated
reviewCount: number; // Pre-calculated
}
Indexing Strategies Define indexes based on your query patterns:
// In your read model repository configuration
const productIndexes = [
{ name: 'by-category', fields: ['categoryId', 'createdAt'] },
{ name: 'by-price-range', fields: ['price', 'categoryId'] },
{ name: 'search', fields: ['name', 'description'], type: 'fulltext' },
];
Claude Code can analyze your query patterns and suggest appropriate indexing strategies. Simply describe your most frequent queries, and it can recommend index configurations.
Storage Backend Options for Read Models
Read models can live in any storage system that fits your query patterns. The choice matters because different backends have different indexing, consistency, and operational trade-offs:
| Backend | Best For | Indexing | Consistency | Ops Complexity |
|---|---|---|---|---|
| PostgreSQL (JSONB) | Flexible schemas, SQL queries | GIN indexes on JSON fields | Strong | Low |
| MongoDB | Document queries, arrays | Compound indexes | Configurable | Medium |
| Redis | Sub-millisecond lookups, caching | Hash/sorted set keys | Eventual | Low |
| Elasticsearch | Full-text search, faceted queries | Analyzed fields | Near real-time | High |
| DynamoDB | Serverless scale, key-value | GSIs | Eventual | Medium |
For most teams starting with CQRS, PostgreSQL with JSONB columns is the pragmatic choice. It offers full SQL query flexibility, strong consistency, and no additional infrastructure. Claude Code can generate the schema and index definitions:
-- Read model tables with JSONB for flexibility
CREATE TABLE order_list_read_model (
order_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
status TEXT NOT NULL,
total NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
data JSONB NOT NULL
);
-- Index for common query patterns
CREATE INDEX idx_order_list_customer ON order_list_read_model (customer_id, created_at DESC);
CREATE INDEX idx_order_list_status ON order_list_read_model (status, created_at DESC);
CREATE INDEX idx_order_list_data_gin ON order_list_read_model USING GIN (data);
Handling Read Model Updates
The synchronization between write and read models requires careful handling. Here are three common approaches:
Event Sourcing with Projections Each state change generates an event. Projections listen to these events and update read models accordingly. This ensures eventual consistency and provides an audit trail.
Dual Write Update both the write database and read models in the same transaction. This provides strong consistency but adds latency to write operations.
Outbox Pattern Write events to an outbox table alongside your main transaction. A separate process polls the outbox and updates read models asynchronously.
For most applications, the event sourcing approach with projections offers the best balance of consistency, performance, and traceability.
Implementing the Outbox Pattern
The outbox pattern is the safest way to guarantee that every write-side change produces a corresponding read model update without distributed transactions. Here is a minimal implementation:
// The outbox table (SQL)
// CREATE TABLE outbox (
// id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
// event_type TEXT NOT NULL,
// payload JSONB NOT NULL,
// created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
// processed BOOLEAN NOT NULL DEFAULT false
// );
// Write side: insert into outbox in the same DB transaction
async function createOrder(command: CreateOrderCommand, db: Database): Promise<void> {
await db.transaction(async (tx) => {
// 1. Apply the write model change
await tx.query(
`INSERT INTO orders (id, customer_id, status) VALUES ($1, $2, 'pending')`,
[command.orderId, command.customerId],
);
// 2. Write the event to the outbox (same transaction)
const event: OrderCreatedEvent = {
type: 'ORDER_CREATED',
orderId: command.orderId,
customerId: command.customerId,
customerName: command.customerName,
customerEmail: command.customerEmail,
items: command.items,
shippingAddress: command.shippingAddress,
timestamp: new Date().toISOString(),
correlationId: command.correlationId,
};
await tx.query(
`INSERT INTO outbox (event_type, payload) VALUES ($1, $2)`,
[event.type, JSON.stringify(event)],
);
});
}
// Outbox relay: poll and dispatch unprocessed events
async function relayOutboxEvents(db: Database, projection: OrderProjection): Promise<void> {
const rows = await db.query(
`SELECT id, event_type, payload
FROM outbox
WHERE processed = false
ORDER BY created_at
LIMIT 100`,
);
for (const row of rows.rows) {
try {
if (row.event_type === 'ORDER_CREATED') {
await projection.handleOrderCreated(row.payload as OrderCreatedEvent);
}
await db.query(`UPDATE outbox SET processed = true WHERE id = $1`, [row.id]);
} catch (err) {
console.error('Outbox relay failed for event', row.id, err);
// Do not mark as processed; retry on next poll
}
}
}
Claude Code can generate the full outbox relay, including exponential backoff, dead-letter handling, and metrics emission. Describe your event types and target projection classes and it will wire everything together.
Testing Read Model Workflows
Claude Code can help generate comprehensive tests for your read models:
describe('OrderReadModel', () => {
it('should aggregate order total from items', async () => {
const event = createOrderCreatedEvent({
items: [
{ productId: 'p1', quantity: 2, price: 10 },
{ productId: 'p2', quantity: 1, price: 25 },
],
});
const projection = new OrderProjection(repository);
await projection.handleOrderCreated(event);
const readModel = await repository.get('orders', event.orderId);
expect(readModel.total).toBe(45);
});
});
Use Claude Code to generate test cases that cover edge cases: empty collections, null values, large datasets, and concurrent updates.
Comprehensive Test Suite Pattern
A thorough test suite for a projection covers happy-path cases, idempotency, ordering guarantees, and projection rebuilds. Claude Code can generate this entire suite once you describe the projection behavior:
// projections/__tests__/order.projection.test.ts
import { OrderProjection } from '../order.projection';
import { InMemoryReadModelRepository } from '../../repositories/in-memory.repository';
import { buildOrderCreatedEvent, buildOrderStatusChangedEvent } from '../../test-helpers/event-builders';
describe('OrderProjection', () => {
let repository: InMemoryReadModelRepository;
let projection: OrderProjection;
beforeEach(() => {
repository = new InMemoryReadModelRepository();
projection = new OrderProjection(repository, console as any);
});
describe('handleOrderCreated', () => {
it('calculates total from line items', async () => {
const event = buildOrderCreatedEvent({
items: [
{ productId: 'A', productName: 'Widget', quantity: 3, unitPrice: 10 },
{ productId: 'B', productName: 'Gadget', quantity: 1, unitPrice: 25 },
],
});
await projection.handleOrderCreated(event);
const detail = await repository.get('order-detail', event.orderId);
expect(detail.total).toBe(55);
});
it('sets initial status to pending', async () => {
const event = buildOrderCreatedEvent();
await projection.handleOrderCreated(event);
const list = await repository.get('order-list', event.orderId);
expect(list.status).toBe('pending');
});
it('is idempotent when applied twice', async () => {
const event = buildOrderCreatedEvent();
await projection.handleOrderCreated(event);
await projection.handleOrderCreated(event); // replay
const items = await repository.getAll('order-list');
expect(items.filter(i => i.orderId === event.orderId)).toHaveLength(1);
});
});
describe('handleOrderStatusChanged', () => {
it('appends to status history without overwriting previous entries', async () => {
const created = buildOrderCreatedEvent();
await projection.handleOrderCreated(created);
const confirmed = buildOrderStatusChangedEvent({
orderId: created.orderId,
newStatus: 'confirmed',
});
const shipped = buildOrderStatusChangedEvent({
orderId: created.orderId,
newStatus: 'shipped',
});
await projection.handleOrderStatusChanged(confirmed);
await projection.handleOrderStatusChanged(shipped);
const detail = await repository.get('order-detail', created.orderId);
expect(detail.statusHistory).toHaveLength(3); // pending + confirmed + shipped
expect(detail.status).toBe('shipped');
});
});
});
The buildOrderCreatedEvent and buildOrderStatusChangedEvent helpers are test factory functions that Claude Code generates alongside the test suite. They provide sensible defaults and allow selective overrides, keeping test intent clear.
Versioning and Rebuilding Projections
Read model schemas change as requirements evolve. When you add a new field to a read model, existing projected documents need to be backfilled. CQRS makes this straightforward because all state lives in the event store, you replay events through the new projection to rebuild the read model from scratch.
Claude Code can generate a rebuild script that streams events in order and replays them through an updated projection class:
// scripts/rebuild-order-projection.ts
import { EventStore } from '../event-store';
import { OrderProjection } from '../projections/order.projection';
import { ReadModelRepository } from '../repositories/read-model.repository';
async function rebuildOrderProjection(): Promise<void> {
const eventStore = new EventStore(process.env.DATABASE_URL!);
const repository = new ReadModelRepository(process.env.READ_MODEL_URL!);
const projection = new OrderProjection(repository, console as any);
// Clear the existing read models for orders
await repository.deleteAll('order-list');
await repository.deleteAll('order-detail');
// Replay all order-related events in order
const eventTypes = ['ORDER_CREATED', 'ORDER_STATUS_CHANGED'];
let cursor: string | undefined;
let processed = 0;
do {
const page = await eventStore.readEvents({ types: eventTypes, afterCursor: cursor, limit: 500 });
for (const event of page.events) {
if (event.type === 'ORDER_CREATED') {
await projection.handleOrderCreated(event.payload);
} else if (event.type === 'ORDER_STATUS_CHANGED') {
await projection.handleOrderStatusChanged(event.payload);
}
processed++;
}
cursor = page.nextCursor;
console.log(`Processed ${processed} events...`);
} while (cursor);
console.log(`Rebuild complete. Total events replayed: ${processed}`);
}
rebuildOrderProjection().catch(console.error);
Projection versioning is also useful for blue/green read model migrations, build the new read model alongside the old one, verify it, then cut over queries to the new version atomically.
Best Practices for CQRS Read Models
Keep these principles in mind as you build and maintain your read models:
Start Simple: Begin with a single read model that covers your most common query. Add more read models only when you identify distinct query patterns that benefit from optimization.
Separate Read and Write Concerns: Never modify read models from command handlers. The projection system handles all read model updates.
Version Your Projections: When read model schemas change, version your projections to handle migration of existing data.
Monitor Consistency: Track the lag between write operations and read model updates. Set alerts for excessive delays.
Read Model Design Decision Guide
Use this table when deciding how to structure a new read model:
| Question | Answer → Action |
|---|---|
| Does the UI need multiple aggregates combined? | Yes → Denormalize into one read model |
| Is the query pattern dominated by a specific filter? | Yes → Add a dedicated index or separate model |
| Do multiple screens share the same data shape? | Yes → Reuse one read model across screens |
| Does the data change at high frequency? | Yes → Consider Redis or a cache TTL |
| Do you need full-text search? | Yes → Use Elasticsearch as the read store |
| Is schema evolution frequent? | Yes → Use JSONB or a document store |
Claude Code can walk through this checklist with you interactively, asking about your UI requirements and query patterns, then generating the appropriate read model interfaces and projection classes.
Conclusion
Claude Code transforms CQRS read model development from manual boilerplate to guided, efficient workflows. By using code generation, pattern recognition, and test automation, you can focus on domain logic while Claude handles the structural complexity. Start with well-defined read models for your most critical queries, and expand as your understanding of query patterns matures.
The key is treating read models as first-class citizens in your architecture, with proper versioning, testing, and monitoring. Claude Code becomes your partner in maintaining this complexity, generating consistent code and catching potential issues before they reach production. Whether you are implementing the outbox pattern for safe synchronization, writing projection rebuild scripts for schema migrations, or generating comprehensive test suites that cover idempotency and ordering edge cases, Claude Code accelerates each step without sacrificing correctness.
Last verified: April 2026. If this approach no longer works, check Mendeley Chrome Extension — Honest Review 2026 for updated steps.
Related Reading
- Claude Code for Mediator Pattern and CQRS Workflow
- Claude Code for Model Card Documentation Workflow
- Claude Code for PyTorch Model Training Workflow
Built by theluckystrike. More at zovo.one
Get started → Generate your project setup with our Project Starter.
See Also
Try it: Paste your error into our Error Diagnostic for an instant fix.