Loading learning content...
The conceptual foundation of CQRS—separating commands from queries—seems straightforward. But the real challenge lies in the details: How do you design models that truly serve their distinct purposes? What belongs in the command model? What shape should read models take? How do you avoid creating two versions of the same rigid structure?
This page addresses the practical art of model design in CQRS systems. We'll explore how to leverage the freedom that separation provides, common pitfalls that undermine that freedom, and patterns that consistently deliver value in production systems.
By the end of this page, you will understand how to design command models focused on business invariant protection, how to design query models optimized for specific use cases, common anti-patterns that create unnecessary coupling, and strategies for evolving models independently.
The command side of a CQRS system handles all state mutations. Its primary responsibilities are:
The command model is where Domain-Driven Design (DDD) patterns shine. Here, you build rich aggregates with methods that enforce business logic.
Command Model Design Principles:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
// COMMAND MODEL: RICH DOMAIN AGGREGATE /** * Order aggregate - the transactional consistency boundary * All invariants are enforced within this aggregate */class Order { private readonly id: OrderId; private readonly customerId: CustomerId; private items: OrderItem[] = []; private status: OrderStatus; private shippingAddress: Address; private discountApplied: Money; private readonly createdAt: Date; private version: number = 0; // Domain events accumulated during command execution private uncommittedEvents: DomainEvent[] = []; // Private constructor - use factory methods private constructor( id: OrderId, customerId: CustomerId, initialItems: CreateOrderItemRequest[], shippingAddress: Address ) { this.id = id; this.customerId = customerId; this.shippingAddress = shippingAddress; this.status = OrderStatus.PENDING; this.discountApplied = Money.zero(); this.createdAt = new Date(); // Validate and add items for (const item of initialItems) { this.addItemInternal(item); } // Emit creation event this.addEvent(new OrderPlacedEvent({ orderId: this.id, customerId: this.customerId, items: this.items.map(i => i.toEventData()), shippingAddress: this.shippingAddress, createdAt: this.createdAt, })); } // ============================================= // FACTORY METHODS: Controlled creation // ============================================= static create( customerId: CustomerId, items: CreateOrderItemRequest[], shippingAddress: Address ): Order { // Pre-creation validation if (items.length === 0) { throw new OrderValidationError('Order must contain at least one item'); } if (!shippingAddress.isComplete()) { throw new OrderValidationError('Complete shipping address required'); } return new Order(OrderId.generate(), customerId, items, shippingAddress); } // ============================================= // COMMAND METHODS: Business operations // ============================================= /** * Add an item to the order * Enforces: Item must have positive quantity, order must be modifiable */ addItem(request: AddItemRequest): void { this.ensureModifiable('add items'); this.addItemInternal(request); this.addEvent(new OrderItemAddedEvent({ orderId: this.id, productId: request.productId, quantity: request.quantity, unitPrice: request.unitPrice, })); } /** * Apply a discount to the order * Enforces: Discount cannot exceed order total, only one discount per order */ applyDiscount(discount: Money, reason: string): void { this.ensureModifiable('apply discount'); if (!this.discountApplied.isZero()) { throw new OrderValidationError('Discount already applied'); } const subtotal = this.calculateSubtotal(); if (discount.isGreaterThan(subtotal)) { throw new OrderValidationError('Discount cannot exceed order subtotal'); } this.discountApplied = discount; this.addEvent(new DiscountAppliedEvent({ orderId: this.id, discountAmount: discount, reason: reason, })); } /** * Confirm the order for fulfillment * Enforces: Order must be pending, minimum order value met */ confirm(): void { if (this.status !== OrderStatus.PENDING) { throw new InvalidStateTransitionError( `Cannot confirm order in status ${this.status}` ); } const total = this.calculateTotal(); if (total.isLessThan(MINIMUM_ORDER_VALUE)) { throw new OrderValidationError( `Order total ${total} below minimum ${MINIMUM_ORDER_VALUE}` ); } this.status = OrderStatus.CONFIRMED; this.version++; this.addEvent(new OrderConfirmedEvent({ orderId: this.id, total: total, confirmedAt: new Date(), })); } /** * Cancel the order * Enforces: Can only cancel pending or confirmed orders */ cancel(reason: string): void { const cancellableStates = [OrderStatus.PENDING, OrderStatus.CONFIRMED]; if (!cancellableStates.includes(this.status)) { throw new InvalidStateTransitionError( `Cannot cancel order in status ${this.status}. \ Orders can only be cancelled when pending or confirmed.` ); } this.status = OrderStatus.CANCELLED; this.version++; this.addEvent(new OrderCancelledEvent({ orderId: this.id, reason: reason, cancelledAt: new Date(), })); } /** * Ship the order * Enforces: Order must be confirmed, valid tracking number required */ ship(trackingNumber: TrackingNumber): void { if (this.status !== OrderStatus.CONFIRMED) { throw new InvalidStateTransitionError( 'Only confirmed orders can be shipped' ); } if (!trackingNumber.isValid()) { throw new OrderValidationError('Valid tracking number required'); } this.status = OrderStatus.SHIPPED; this.version++; this.addEvent(new OrderShippedEvent({ orderId: this.id, trackingNumber: trackingNumber, shippedAt: new Date(), estimatedDelivery: trackingNumber.getEstimatedDelivery(), })); } // ============================================= // PRIVATE HELPERS: Internal logic // ============================================= private addItemInternal(request: CreateOrderItemRequest): void { if (request.quantity <= 0) { throw new OrderValidationError('Item quantity must be positive'); } if (request.unitPrice.isNegative()) { throw new OrderValidationError('Unit price cannot be negative'); } // Check if product already in order const existing = this.items.find(i => i.productId.equals(request.productId)); if (existing) { existing.increaseQuantity(request.quantity); } else { this.items.push(OrderItem.create(request)); } } private ensureModifiable(operation: string): void { if (this.status !== OrderStatus.PENDING) { throw new InvalidStateTransitionError( `Cannot ${operation} for order in status ${this.status}` ); } } private calculateSubtotal(): Money { return this.items.reduce( (total, item) => total.add(item.getLineTotal()), Money.zero() ); } private calculateTotal(): Money { return this.calculateSubtotal().subtract(this.discountApplied); } private addEvent(event: DomainEvent): void { this.uncommittedEvents.push(event); } // ============================================= // PUBLIC ACCESSORS: Limited read access // ============================================= getId(): OrderId { return this.id; } getVersion(): number { return this.version; } getUncommittedEvents(): DomainEvent[] { return [...this.uncommittedEvents]; } clearUncommittedEvents(): void { this.uncommittedEvents = []; }}Notice how the command model has methods like 'confirm()', 'ship()', and 'applyDiscount()'—not 'updateStatus()' or 'setField()'. Each method represents a meaningful business operation with its own validation rules. This task-based design makes the code self-documenting and ensures business rules are consistently enforced.
Command Handlers: Orchestrating the Command Model
Commands arrive at the system through command handlers. These handlers:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
// COMMAND DEFINITIONS // Commands are immutable data structures representing user intentclass ConfirmOrderCommand { readonly type = 'ConfirmOrder'; constructor( public readonly orderId: string, public readonly confirmedBy: string ) { Object.freeze(this); }} class ShipOrderCommand { readonly type = 'ShipOrder'; constructor( public readonly orderId: string, public readonly trackingNumber: string, public readonly carrier: string ) { Object.freeze(this); }} // COMMAND HANDLER class OrderCommandHandler { constructor( private readonly orderRepository: OrderRepository, private readonly eventPublisher: EventPublisher, private readonly unitOfWork: UnitOfWork ) {} async handleConfirmOrder(command: ConfirmOrderCommand): Promise<void> { await this.unitOfWork.execute(async () => { // 1. Load aggregate from repository const order = await this.orderRepository.getById( new OrderId(command.orderId) ); if (!order) { throw new OrderNotFoundError(command.orderId); } // 2. Execute business operation (validation happens inside) order.confirm(); // 3. Persist updated aggregate await this.orderRepository.save(order); // 4. Publish domain events await this.eventPublisher.publish(order.getUncommittedEvents()); order.clearUncommittedEvents(); }); } async handleShipOrder(command: ShipOrderCommand): Promise<void> { await this.unitOfWork.execute(async () => { const order = await this.orderRepository.getById( new OrderId(command.orderId) ); if (!order) { throw new OrderNotFoundError(command.orderId); } const trackingNumber = new TrackingNumber( command.trackingNumber, command.carrier ); order.ship(trackingNumber); await this.orderRepository.save(order); await this.eventPublisher.publish(order.getUncommittedEvents()); order.clearUncommittedEvents(); }); }} // REPOSITORY IMPLEMENTATION class OrderRepository { constructor(private readonly database: Database) {} async getById(id: OrderId): Promise<Order | null> { const record = await this.database.queryOne( 'SELECT * FROM orders WHERE id = $1', [id.value] ); if (!record) return null; const itemRecords = await this.database.query( 'SELECT * FROM order_items WHERE order_id = $1', [id.value] ); return OrderMapper.toDomain(record, itemRecords); } async save(order: Order): Promise<void> { // Optimistic concurrency check const result = await this.database.execute( `UPDATE orders SET status = $1, discount_applied = $2, version = $3, updated_at = NOW() WHERE id = $4 AND version = $5`, [ order.getStatus(), order.getDiscountApplied(), order.getVersion(), order.getId().value, order.getVersion() - 1 // Expected previous version ] ); if (result.rowCount === 0) { throw new ConcurrencyError( 'Order was modified by another process' ); } }}The query side of a CQRS system is optimized purely for reading and displaying data. It has no business logic, no validation, and no state transitions. Its sole purpose is to answer questions efficiently.
Query Model Design Principles:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
// QUERY MODELS: DESIGNED FOR SPECIFIC USE CASES // =============================================// READ MODEL 1: Order List View// Used by: My Orders page, Admin order list// Optimized for: Quick scanning, pagination// ============================================= interface OrderListItemDto { orderId: string; orderNumber: string; // Human-readable order number customerName: string; // Denormalized - no customer lookup needed itemCount: number; // Pre-computed totalAmount: number; currency: string; status: string; // Displayable status text statusColor: string; // UI hint for styling createdAt: string; // Pre-formatted date canCancel: boolean; // Pre-computed permission} // =============================================// READ MODEL 2: Order Detail View// Used by: Order detail page// Optimized for: Complete information display// ============================================= interface OrderDetailDto { orderId: string; orderNumber: string; // Customer info (denormalized) customer: { id: string; name: string; email: string; memberSince: string; totalOrders: number; // Pre-aggregated }; // Line items with product details (denormalized) items: Array<{ productId: string; productName: string; productImage: string; // Thumbnail URL sku: string; quantity: number; unitPrice: number; lineTotal: number; }>; // Pre-computed totals subtotal: number; discount: number; discountReason: string | null; tax: number; shipping: number; total: number; // Addresses (full objects, not IDs) shippingAddress: AddressDto; billingAddress: AddressDto; // Status with timeline status: string; statusTimeline: Array<{ status: string; timestamp: string; actor: string; // Who triggered the transition }>; // Pre-computed actions availableActions: string[]; // ['cancel', 'contact-support'] // Tracking info (if shipped) tracking?: { carrier: string; trackingNumber: string; trackingUrl: string; estimatedDelivery: string; lastUpdate: string; };} // =============================================// READ MODEL 3: Order Analytics// Used by: Admin dashboard, reports// Optimized for: Aggregations, time-series// ============================================= interface OrderAnalyticsDto { period: string; // '2024-Q1', '2024-01', etc. volume: { totalOrders: number; completedOrders: number; cancelledOrders: number; completionRate: number; // Pre-computed percentage }; revenue: { grossRevenue: number; discountsGiven: number; netRevenue: number; averageOrderValue: number; }; // Top performers (pre-aggregated) topProducts: Array<{ productId: string; productName: string; unitsSold: number; revenue: number; }>; topCustomers: Array<{ customerId: string; customerName: string; orderCount: number; totalSpent: number; }>; // Time-series for charts dailyTrends: Array<{ date: string; orderCount: number; revenue: number; }>;} // =============================================// READ MODEL 4: Search Results// Used by: Order search, quick lookup// Optimized for: Full-text search, filtering// ============================================= interface OrderSearchResultDto { orderId: string; orderNumber: string; customerName: string; customerEmail: string; productNames: string[]; // For search highlighting status: string; total: number; createdAt: string; // Search metadata searchScore: number; // Relevance score matchedFields: string[]; // Which fields matched}The OrderDetailDto contains the complete customer name, not a customerId that requires a separate lookup. The items contain productName and productImage, not just productId. This denormalization eliminates runtime JOINs and enables read-side storage in document databases or key-value stores.
Query Handlers: Direct Database Access
Unlike command handlers that load and manipulate aggregates, query handlers go directly to the database. There's no domain model involved—just data retrieval.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
// QUERY DEFINITIONS class GetOrderListQuery { constructor( public readonly customerId: string, public readonly page: number = 1, public readonly pageSize: number = 20, public readonly status?: string ) {}} class GetOrderDetailQuery { constructor( public readonly orderId: string ) {}} class SearchOrdersQuery { constructor( public readonly searchTerm: string, public readonly filters: OrderFilters, public readonly page: number = 1, public readonly pageSize: number = 50 ) {}} // QUERY HANDLERS class OrderQueryHandler { constructor( private readonly readDatabase: ReadDatabase, private readonly searchIndex: SearchIndex, private readonly cache: CacheClient ) {} // List view: Simple paginated query async getOrderList(query: GetOrderListQuery): Promise<PaginatedResult<OrderListItemDto>> { // Direct query against denormalized table // No domain objects, no aggregate loading const result = await this.readDatabase.query<OrderListItemDto>(` SELECT order_id as "orderId", order_number as "orderNumber", customer_name as "customerName", item_count as "itemCount", total_amount as "totalAmount", currency, status, status_color as "statusColor", TO_CHAR(created_at, 'YYYY-MM-DD') as "createdAt", can_cancel as "canCancel" FROM order_list_view WHERE customer_id = $1 AND ($2::text IS NULL OR status = $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4 `, [ query.customerId, query.status, query.pageSize, (query.page - 1) * query.pageSize ]); const total = await this.readDatabase.queryScalar<number>(` SELECT COUNT(*) FROM order_list_view WHERE customer_id = $1 AND ($2::text IS NULL OR status = $2) `, [query.customerId, query.status]); return { items: result, page: query.page, pageSize: query.pageSize, totalItems: total, totalPages: Math.ceil(total / query.pageSize) }; } // Detail view: Single document lookup (with caching) async getOrderDetail(query: GetOrderDetailQuery): Promise<OrderDetailDto | null> { // Try cache first const cacheKey = `order:detail:${query.orderId}`; const cached = await this.cache.get<OrderDetailDto>(cacheKey); if (cached) return cached; // Query denormalized detail (single table, no JOINs) const result = await this.readDatabase.queryOne<OrderDetailDto>(` SELECT data FROM order_detail_documents WHERE order_id = $1 `, [query.orderId]); if (result) { // Cache for 5 minutes await this.cache.set(cacheKey, result, { ttl: 300 }); } return result; } // Search: Full-text search with facets async searchOrders(query: SearchOrdersQuery): Promise<SearchResult<OrderSearchResultDto>> { // Use dedicated search infrastructure return this.searchIndex.search<OrderSearchResultDto>('orders', { query: query.searchTerm, filters: this.buildFilters(query.filters), from: (query.page - 1) * query.pageSize, size: query.pageSize, highlight: ['customerName', 'customerEmail', 'productNames'], aggregations: { byStatus: { field: 'status' }, byMonth: { field: 'createdAt', interval: 'month' } } }); } private buildFilters(filters: OrderFilters): SearchFilter[] { const result: SearchFilter[] = []; if (filters.status) { result.push({ field: 'status', value: filters.status }); } if (filters.dateFrom) { result.push({ field: 'createdAt', gte: filters.dateFrom }); } if (filters.dateTo) { result.push({ field: 'createdAt', lte: filters.dateTo }); } if (filters.minAmount) { result.push({ field: 'total', gte: filters.minAmount }); } return result; }}Notice how different queries use different backends: the list view uses a relational database, the detail view uses caching, and search uses a dedicated search index. CQRS enables this flexibility because read models are independent of the write model.
One of CQRS's most powerful capabilities is supporting multiple optimized read models from a single write model. Each read model serves a specific purpose and can use entirely different storage technologies.
Multi-Read-Model Architecture:
┌─────────────────────────────────────┐
│ WRITE MODEL │
│ ┌─────────────────────────────┐ │
│ │ Order Aggregate │ │
│ └─────────────┬───────────────┘ │
│ │ │
│ ┌─────────────▼───────────────┐ │
│ │ PostgreSQL (Primary) │ │
│ └─────────────┬───────────────┘ │
└────────────────┼────────────────────┘
│
Events Published │
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐ ┌───────────────────────────┐
│ READ MODEL 1 │ │ READ MODEL 2 │ │ READ MODEL 3 │
│ Order List View │ │ Order Search │ │ Analytics Dashboard │
│ │ │ │ │ │
│ ┌─────────────────────┐ │ │ ┌─────────────────────┐ │ │ ┌─────────────────────┐ │
│ │ PostgreSQL │ │ │ │ Elasticsearch │ │ │ │ ClickHouse │ │
│ │ (Denormalized View) │ │ │ │ (Full-text Index) │ │ │ │ (Time-series OLAP) │ │
│ └─────────────────────┘ │ │ └─────────────────────┘ │ │ └─────────────────────┘ │
│ │ │ │ │ │
│ Use case: List pages, │ │ Use case: Search, │ │ Use case: Reports, │
│ pagination, filtering │ │ autocomplete, facets │ │ charts, aggregations │
└───────────────────────────┘ └───────────────────────────┘ └───────────────────────────┘
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
// PROJECTION HANDLERS: Update each read model from events class ReadModelProjectionService { constructor( private readonly listViewDb: PostgresDatabase, private readonly searchIndex: ElasticsearchClient, private readonly analyticsDb: ClickHouseClient, private readonly cache: RedisClient ) {} // ============================================= // ORDER PLACED: Update all read models // ============================================= @EventHandler(OrderPlacedEvent) async handleOrderPlaced(event: OrderPlacedEvent): Promise<void> { // Parallel updates to all read models await Promise.all([ this.updateListView(event), this.updateSearchIndex(event), this.updateAnalytics(event), this.invalidateCache(event) ]); } private async updateListView(event: OrderPlacedEvent): Promise<void> { await this.listViewDb.insert('order_list_view', { order_id: event.orderId, order_number: event.orderNumber, customer_id: event.customerId, customer_name: event.customerName, // Denormalized item_count: event.items.length, total_amount: this.calculateTotal(event.items), currency: event.currency, status: 'Pending', status_color: 'yellow', created_at: event.timestamp, can_cancel: true }); // Also create the detail view document await this.listViewDb.insert('order_detail_documents', { order_id: event.orderId, data: JSON.stringify(this.buildDetailDto(event)) }); } private async updateSearchIndex(event: OrderPlacedEvent): Promise<void> { await this.searchIndex.index('orders', event.orderId, { orderId: event.orderId, orderNumber: event.orderNumber, customerId: event.customerId, customerName: event.customerName, customerEmail: event.customerEmail, productNames: event.items.map(i => i.productName), productSkus: event.items.map(i => i.sku), status: 'pending', total: this.calculateTotal(event.items), createdAt: event.timestamp, // Searchable combined field searchableText: [ event.orderNumber, event.customerName, event.customerEmail, ...event.items.map(i => i.productName) ].join(' ') }); } private async updateAnalytics(event: OrderPlacedEvent): Promise<void> { await this.analyticsDb.insert('order_events', { event_type: 'placed', order_id: event.orderId, customer_id: event.customerId, total_amount: this.calculateTotal(event.items), item_count: event.items.length, timestamp: event.timestamp, // Dimensions for analytics date: event.timestamp.toISOString().split('T')[0], hour: event.timestamp.getHours(), day_of_week: event.timestamp.getDay() }); // Update product metrics for (const item of event.items) { await this.analyticsDb.insert('product_order_events', { product_id: item.productId, order_id: event.orderId, quantity: item.quantity, revenue: item.quantity * item.unitPrice, timestamp: event.timestamp }); } } private async invalidateCache(event: OrderPlacedEvent): Promise<void> { // Invalidate customer's order list cache await this.cache.del(`customer:${event.customerId}:orders`); // Invalidate dashboard caches await this.cache.del('dashboard:order-stats'); } // ============================================= // ORDER SHIPPED: Update status across read models // ============================================= @EventHandler(OrderShippedEvent) async handleOrderShipped(event: OrderShippedEvent): Promise<void> { await Promise.all([ this.updateListViewStatus(event), this.updateSearchIndexStatus(event), this.addAnalyticsEvent(event), this.updateDetailWithTracking(event), this.invalidateCacheForOrder(event.orderId) ]); } private async updateListViewStatus(event: OrderShippedEvent): Promise<void> { await this.listViewDb.update('order_list_view', { order_id: event.orderId }, { status: 'Shipped', status_color: 'blue', can_cancel: false } ); } private async updateDetailWithTracking(event: OrderShippedEvent): Promise<void> { // Get current detail, add tracking, save const current = await this.listViewDb.queryOne<{ data: string }>( 'SELECT data FROM order_detail_documents WHERE order_id = $1', [event.orderId] ); if (current) { const detail = JSON.parse(current.data); detail.status = 'Shipped'; detail.tracking = { carrier: event.carrier, trackingNumber: event.trackingNumber, trackingUrl: this.buildTrackingUrl(event.carrier, event.trackingNumber), estimatedDelivery: event.estimatedDelivery, lastUpdate: event.timestamp }; detail.statusTimeline.push({ status: 'Shipped', timestamp: event.timestamp, actor: event.shippedBy }); detail.availableActions = ['track', 'contact-support']; await this.listViewDb.update('order_detail_documents', { order_id: event.orderId }, { data: JSON.stringify(detail) } ); } }}| Use Case | Recommended Technology | Why |
|---|---|---|
| Paginated lists with filtering | PostgreSQL (materialized views) | Strong query flexibility, ACID for updates |
| Full-text search | Elasticsearch / Opensearch | Built for search, facets, highlighting |
| Real-time dashboards | ClickHouse / TimescaleDB | Optimized for time-series aggregations |
| Document retrieval by ID | Redis / MongoDB | Sub-millisecond lookups |
| Graph traversals | Neo4j | Native graph queries |
| Geospatial queries | PostGIS / Elasticsearch | Specialized geo indexing |
Multiple read models introduce eventual consistency. After a command completes, read models may take milliseconds to seconds to update. Design your UI/UX to handle this—for example, showing the user's just-placed order immediately from the command response rather than querying the read model.
Many CQRS implementations fail to realize the pattern's benefits because they don't actually separate concerns. Here are anti-patterns that undermine the architecture:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// ❌ ANTI-PATTERN: MIRROR MODELS // Write modelclass Order { id: string; customerId: string; items: OrderItem[]; status: string; createdAt: Date;} // Read model (BAD: Nearly identical structure!)interface OrderDto { id: string; customerId: string; // Still requires customer lookup! items: OrderItemDto[]; // Still requires JOIN to items table! status: string; createdAt: string;} class OrderQueryService { async getOrder(id: string): Promise<OrderDto> { // PROBLEM: Same queries as before CQRS const order = await this.orderRepo.findById(id); const items = await this.itemRepo.findByOrderId(id); const customer = await this.customerRepo.findById(order.customerId); // Just mapping, not optimizing return { id: order.id, customerId: order.customerId, // Why not customerName? items: items.map(i => ({ ...i })), // Why not product details? status: order.status, createdAt: order.createdAt.toISOString() }; }} // ✅ CORRECT: USE-CASE SPECIFIC MODELS // Read model designed for ORDER LIST PAGEinterface OrderListDto { id: string; orderNumber: string; // Formatted for display customerName: string; // Denormalized - no lookup productNamesPreview: string; // "Widget, Gadget, and 3 more..." itemCount: number; // Pre-computed total: string; // Pre-formatted with currency status: string; statusBadgeColor: string; // UI hint formattedDate: string; // "Jan 15, 2024" timeAgo: string; // "2 days ago"} // THIS is what CQRS enables - data shaped for the UIAnti-pattern 2: Single Universal Read Model
Attempting to create one read model that serves all possible queries leads to either bloated models or suboptimal queries.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// ❌ ANTI-PATTERN: ONE MODEL TO RULE THEM ALL interface UniversalOrderDto { // Fields for list view id: string; orderNumber: string; customerName: string; total: number; status: string; // Fields for detail view (always fetched, rarely used) customer: CustomerDto; items: OrderItemDto[]; addresses: { shipping: AddressDto; billing: AddressDto }; paymentInfo: PaymentDto; timeline: StatusTimelineDto[]; // Fields for analytics (always fetched, rarely used) profitMargin: number; customerLifetimeValue: number; // Fields for admin (always fetched, rarely used) auditLog: AuditEntry[]; internalNotes: string;} // PROBLEM: List view fetches 10KB of data when it only needs 200 bytes// PROBLEM: Every query includes expensive computations// PROBLEM: Can't optimize storage for specific access patterns // ✅ CORRECT: SEPARATE MODELS FOR EACH USE CASE // Model 1: Lightweight listinterface OrderListDto { /* minimal fields */ } // Model 2: Complete detailinterface OrderDetailDto { /* full fields */ } // Model 3: Analytics aggregatesinterface OrderAnalyticsDto { /* pre-computed metrics */ } // Model 4: Admin with auditinterface OrderAdminDto { /* includes internal fields */ } // Each stored optimally, queried efficientlyOne of CQRS's major advantages is the ability to evolve read and write models independently. As requirements change, you can modify one side without affecting the other—if you've structured the system correctly.
Command Model Evolution:
Changes to the command model typically involve:
These changes may generate new events but shouldn't require read model changes unless the new data needs to be displayed.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
// SCENARIO: Adding "express shipping" to the order system // =============================================// COMMAND MODEL CHANGE// ============================================= // New commandclass SelectShippingOptionCommand { constructor( public readonly orderId: string, public readonly shippingOption: 'standard' | 'express' | 'overnight' ) {}} // Modified aggregate (new method + state)class Order { private shippingOption: ShippingOption = ShippingOption.STANDARD; private shippingCost: Money; // NEW COMMAND METHOD selectShippingOption(option: ShippingOption): void { this.ensureModifiable('select shipping'); this.shippingOption = option; this.shippingCost = this.calculateShippingCost(option); this.addEvent(new ShippingOptionSelectedEvent({ orderId: this.id, shippingOption: option, shippingCost: this.shippingCost, estimatedDelivery: this.calculateEstimatedDelivery(option) })); } // MODIFIED: Total now includes shipping private calculateTotal(): Money { return this.calculateSubtotal() .subtract(this.discountApplied) .add(this.shippingCost); }} // =============================================// READ MODEL CHANGE (Independent!)// ============================================= // The read model team can add shipping display when ready// They could even delay this if not immediately needed // New event handler@EventHandler(ShippingOptionSelectedEvent)async handleShippingOptionSelected(event: ShippingOptionSelectedEvent): Promise<void> { await this.listViewDb.update('order_list_view', { order_id: event.orderId }, { shipping_option: event.shippingOption, shipping_cost: event.shippingCost, estimated_delivery: event.estimatedDelivery } ); // Update detail view await this.updateDetailJson(event.orderId, (detail) => ({ ...detail, shipping: { option: event.shippingOption, cost: event.shippingCost, estimatedDelivery: event.estimatedDelivery }, // Recalculate displayed total total: detail.subtotal - detail.discount + event.shippingCost }));} // =============================================// ADDING NEW READ MODEL (No command changes!)// ============================================= // Later: Marketing team wants shipping analytics// This requires NO changes to command model // New read modelinterface ShippingAnalyticsDto { period: string; optionBreakdown: { standard: { count: number; revenue: number }; express: { count: number; revenue: number }; overnight: { count: number; revenue: number }; }; averageShippingCost: number; expressAdoptionRate: number;} // New projection (subscribes to existing events)@EventHandler(ShippingOptionSelectedEvent)async updateShippingAnalytics(event: ShippingOptionSelectedEvent): Promise<void> { await this.analyticsDb.insert('shipping_events', { order_id: event.orderId, shipping_option: event.shippingOption, shipping_cost: event.shippingCost, timestamp: event.timestamp });} // Query handlerasync getShippingAnalytics(period: string): Promise<ShippingAnalyticsDto> { return this.analyticsDb.query(` SELECT $1 as period, countIf(shipping_option = 'standard') as standard_count, sumIf(shipping_cost, shipping_option = 'standard') as standard_revenue, -- ... more aggregations FROM shipping_events WHERE toYYYYMM(timestamp) = $1 `, [period]);}Notice how events serve as the contract between command and query sides. The command model doesn't know what read models exist or how they're structured. It simply publishes events describing what happened. Read models subscribe to events they care about. This is how independence is achieved.
We've explored the art of designing command and query models in CQRS systems. Here are the essential principles:
What's Next:
In the next page, we'll explore When CQRS Helps—the specific scenarios where CQRS provides significant value, and equally importantly, when simpler approaches are more appropriate. Understanding the decision criteria prevents both premature adoption and missed opportunities.
You now understand how to design effective command models that protect business invariants and query models optimized for specific use cases. This separation is the heart of CQRS's power—enabling each side to excel at its purpose without compromise.