Mastering CQRS: When to Separate Read and Write in DDD
This article explains how CQRS splits write operations that enforce business rules using domain models from read operations optimized for page performance, illustrates common pitfalls of mixing the two, and provides concrete Java code, SQL examples, migration steps, validation checklists, and best‑practice guidelines for implementing lightweight CQRS in a DDD‑based backend.
What CQRS Means
CQRS (Command Query Responsibility Segregation) separates write and read concerns. The write side uses a domain model to enforce business rules, while the read side organizes data for pages and performance.
Why Separate Write and Read
Aggregates excel at write‑heavy operations (order creation, payment, cancellation) but struggle with backend lists, reports, and complex filters. Mixing both leads to N+1 queries, repository bloat, and slow main‑database queries.
Layer Placement (DDD Stack)
User Interface : receives PlaceOrderRequest (write) and OrderPageRequest (read).
Application : PlaceOrderAppService orchestrates the use case; OrderQueryService orchestrates queries.
Domain : Order aggregate contains business rules; the read side does not touch the domain.
Infrastructure : OrderRepository persists aggregates; OrderQueryDao executes SQL/ES queries.
Database : write tables t_order, t_order_item; read side uses joins, wide tables, or read‑only views.
Write‑Side Example
Command definition – a record that describes the business intent:
public record PlaceOrderCommand(
Long memberId,
List<OrderLineCommand> lines,
AddressCommand address,
Long couponId) {}Application service orchestrates the use case, obtains a product snapshot, calculates promotion, creates the aggregate, saves it, and publishes domain events:
@Service
public class PlaceOrderAppService {
private final OrderRepository orderRepository;
private final ProductSnapshotService productSnapshotService;
private final PromotionService promotionService;
private final DomainEventPublisher eventPublisher;
@Transactional
public OrderId placeOrder(PlaceOrderCommand command) {
// 1. Get product snapshot to avoid later price changes
List<ProductSnapshot> snapshots = productSnapshotService.snapshot(command.lines());
// 2. Calculate promotion
Money discount = promotionService.calculate(
command.memberId(), command.couponId(), snapshots);
// 3. Create aggregate – business rules live inside Order
Order order = Order.place(new MemberId(command.memberId()), snapshots,
Address.from(command.address()), discount);
// 4. Persist and publish events
orderRepository.save(order);
eventPublisher.publishAll(order.pullDomainEvents());
return order.getId();
}
}Aggregate logic – only the aggregate can change its state:
public class Order {
private OrderStatus status;
private Money totalAmount;
private int version;
public void pay(Money paidAmount) {
if (status != OrderStatus.CREATED) {
throw new BusinessException("Only CREATED orders can be paid");
}
if (!totalAmount.equals(paidAmount)) {
throw new BusinessException("Paid amount does not match");
}
this.status = OrderStatus.PAID;
this.version++;
}
// other rules (cancel, etc.)
}Read‑Side Example
Query object – carries pagination and filter parameters only:
public record OrderPageQuery(
Long memberId,
String memberKeyword,
String productKeyword,
String status,
LocalDateTime createdFrom,
LocalDateTime createdTo,
Integer pageNo,
Integer pageSize) {
public int safePageNo() { return (pageNo == null || pageNo < 1) ? 1 : pageNo; }
public int safePageSize() { return (pageSize == null) ? 20 : Math.min(Math.max(pageSize, 1), 100); }
public int offset() { return (safePageNo() - 1) * safePageSize(); }
}DTO for a list item – shaped for the UI, not the aggregate:
public record OrderListItemDTO(
Long orderId,
String orderNo,
String memberNickname,
Integer memberLevel,
String firstProductName,
BigDecimal totalAmount,
String statusText,
String paymentChannel,
LocalDateTime createdAt) {}Query service delegates pagination, permission checks, and returns a PageResult<OrderListItemDTO>:
@Service
public class OrderQueryService {
private final OrderQueryDao orderQueryDao;
public PageResult<OrderListItemDTO> pageOrders(OrderPageQuery query) {
List<OrderListItemDTO> rows = orderQueryDao.pageOrders(query);
long total = orderQueryDao.countOrders(query);
return PageResult.of(rows, total, query.safePageNo(), query.safePageSize());
}
}DAO interface (read‑only, placed in the infrastructure layer):
public interface OrderQueryDao {
List<OrderListItemDTO> pageOrders(OrderPageQuery query);
long countOrders(OrderPageQuery query);
}SQL for the list page – joins member, order‑item, payment tables and maps status to human‑readable text:
SELECT
o.id AS order_id,
o.order_no,
m.nickname AS member_nickname,
m.level AS member_level,
oi.product_name AS first_product_name,
o.total_amount,
CASE o.status
WHEN 'CREATED' THEN '待支付'
WHEN 'PAID' THEN '已支付'
WHEN 'CANCELLED' THEN '已取消'
ELSE '未知'
END AS status_text,
p.channel AS payment_channel,
o.created_at
FROM t_order o
LEFT JOIN t_member m ON m.id = o.member_id
LEFT JOIN t_order_item oi ON oi.order_id = o.id AND oi.item_index = 1
LEFT JOIN t_payment p ON p.order_id = o.id
WHERE o.created_at BETWEEN #{createdFrom} AND #{createdTo}
AND (#{status} IS NULL OR o.status = #{status})
ORDER BY o.created_at DESC
LIMIT #{offset}, #{pageSize};From CRUD to Lightweight CQRS
Do not refactor the whole system at once. Start with the most painful query interface.
Extract it into a dedicated QueryService, move pagination and reporting code out of the OrderRepository.
Commands should return only identifiers; queries should return DTOs.
Validation & Checklist
Search the write‑side repository for page‑ or list‑related methods (e.g., pageAdminOrders). Their presence means query logic has polluted the write side.
Search the query package for state‑changing calls such as .pay(, .cancel(, or setStatus. Any match indicates a boundary breach.
Ensure command packages do not import DTOs or PageResult types.
Read models should contain tracing fields ( source_event_id, source_version, projected_at) to help debug projection lag.
Verify event‑consumer idempotency by creating a t_processed_event table with event_id as primary key.
Run EXPLAIN on heavy query SQL and add appropriate indexes (e.g., idx_order_status_created on (status, created_at)).
Common Pitfalls (Converted from Table)
Equating CQRS with micro‑services → unnecessary complexity.
Adding MQ, ES, event sourcing from the start → high operational cost.
Query side loading aggregates → N+1 and slow UI.
Repository handling pagination → repository becomes a generic DAO.
Read model without source event trace → hard to debug sync issues.
Ignoring idempotency of events → duplicate or corrupt data.
Not handling event ordering → state rollback.
Read side making business decisions → rule duplication.
Code‑Review Checklist (Converted from Table)
Clear interface type – can state whether it is Command or Query.
Command name expresses intent (e.g., PayOrderCommand instead of UpdateOrderStatusCommand).
Business rules live in the aggregate.
Repository only stores aggregates (no pagination or reporting methods).
Query side does not load aggregates – returns DTOs directly.
DTO matches UI contract.
Permission & masking handled in query (e.g., phone number masking).
Read model sync method documented (same‑DB transaction, event, or CDC).
Event consumption idempotent (uses event_id or business unique key).
Out‑of‑order protection (version number or state machine prevents rollback).
Final consistency communicated to product owners.
Read model rebuildable (script or replay mechanism exists).
Project Directory Layout (Illustrative)
com.youxuan.store.order
├─ interfaces
│ ├─ OrderCommandController.java // write API: place, pay, cancel
│ └─ OrderQueryController.java // read API: list, detail, report
├─ application
│ ├─ command
│ │ ├─ PlaceOrderCommand.java
│ │ └─ PayOrderCommand.java
│ └─ query
│ └─ OrderPageQuery.java
├─ domain
│ ├─ model
│ │ ├─ Order.java
│ │ └─ OrderItem.java
│ └─ repository
│ └─ OrderRepository.java // only store/retrieve aggregates
└─ infrastructure
├─ persistence
│ └─ MyBatisOrderRepository.java
└─ query
└─ OrderQueryDao.java // MyBatis mapper for list/report queriesStep‑by‑Step Migration Example
Original mixed service:
@Service
public class OrderService {
public void pay(Long orderId, BigDecimal amount) { /* write */ }
public PageResult<OrderListItemDTO> pageAdminOrders(OrderPageQuery query) { /* read */ }
}After extraction:
// Write side
public class OrderCommandService {
public OrderId placeOrder(PlaceOrderCommand cmd) { /* ... */ }
public void payOrder(PayOrderCommand cmd) { /* ... */ }
public void cancelOrder(CancelOrderCommand cmd) { /* ... */ }
}
// Read side
public class OrderQueryService {
public PageResult<OrderListItemDTO> pageAdminOrders(OrderPageQuery query) { /* ... */ }
public OrderDetailDTO getOrderDetail(Long orderId) { /* ... */ }
public ExportDTO exportOrders(ExportQuery query) { /* ... */ }
}Verification Commands (Shell snippets)
Check repository for query pollution:
rg "Page|List|Report|Admin|Query" src/main/java/com/youxuan/store/**/domain/repository(or
grep -R "Page\|List\|Report\|Admin\|Query" src/main/java/com/youxuan/store/**/domain/repository)
Check query side for state‑changing calls:
rg "\.pay\(|\.cancel\(|\.ship\(|setStatus" src/main/java/com/youxuan/store/**/application/queryEnsure write‑side commands do not return page DTOs:
rg "DTO|VO|PageResult|Export" src/main/java/com/youxuan/store/**/application/commandRead Model Traceability
Add tracing columns to read tables:
ALTER TABLE r_order_list
ADD COLUMN source_event_id BIGINT NULL,
ADD COLUMN source_version INT NULL,
ADD COLUMN projected_at DATETIME NULL;Query to verify projection lag:
SELECT order_id, status, source_event_id, source_version, projected_at
FROM r_order_list
WHERE order_id = 1001;Event‑Consumption Idempotency Table
CREATE TABLE t_processed_event (
event_id BIGINT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
processed_at DATETIME NOT NULL
);Consumer pattern:
Check if event_id already exists.
If not, apply the event to the read model.
Insert event_id into t_processed_event.
SQL Performance Checks
Run EXPLAIN on the list query and ensure the plan uses appropriate indexes (e.g., idx_order_status_created on (status, created_at)).
EXPLAIN SELECT o.id, o.order_no, m.nickname, o.status, o.created_at
FROM t_order o
LEFT JOIN t_member m ON m.id = o.member_id
WHERE o.status = 'PAID' AND o.created_at BETWEEN '2026-07-01' AND '2026-07-31'
LIMIT 0, 20;Typical type = ALL indicates a full table scan; key = NULL means no index was used.
Common Misconceptions
CQRS is not a micro‑service architecture; it is a component‑level pattern.
Event sourcing is optional; CQRS does not require it.
Read side may use joins and wide tables, but it should not load aggregates.
Final consistency must be communicated to product owners to avoid perceived bugs.
Six Takeaways
Simple CRUD does not need CQRS.
Complex core domains benefit from same‑DB read/write separation.
Write side = Aggregate + Repository + Domain events.
Read side = Query Service + QueryDao + DTO.
If you extract a read model, handle eventual consistency, idempotency, ordering, and rebuildability.
Event sourcing is optional; CQRS does not mandate it.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Yumin Fish Harvest
A deep‑sea salvage fisherman sharing architecture insights, practical tips, and lessons learned.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
