在DDD(领域驱动设计)中,应用层的Command和Query是CQRS(命令查询职责分离)模式的核心概念,它们的设计讲究直接影响代码的可维护性和业务表达的清晰度。下面系统梳理一下关键要点。
核心理念:为什么要在应用层区分Command和Query
CQRS的核心原则来自Bertrand Meyer提出的命令查询分离原则(CQS):
- Command(命令):一个方法如果修改了系统状态,它就是Command。它不应该返回业务数据,只返回操作结果(成功/失败/异步已接收)。
- Query(查询):一个方法如果返回了数据,它就是Query。它不应该通过直接或间接的手段修改系统状态。
在DDD应用层中,这不仅仅是理论原则,更落地为具体的对象设计规范。
Command对象的设计讲究
Command代表调用方明确想让系统执行的操作指令,预期会对系统产生副作用(写操作)。
设计要点
- 语义化命名:Command的名字必须能清晰表达"意图",而非"动作"。例如
PlaceOrderCommand(下单指令)比CreateOrderCommand(创建订单)更有业务含义。 - 封装操作所需的全部参数:把散落的多个参数收拢到一个Command对象中,避免接口签名膨胀。
- 不包含业务逻辑:Command本身是Value Object,只携带数据,不包含规则。
- 可以包含校验逻辑:Command上可以做基础的数据格式校验(如非空、范围),但业务规则校验应交给领域层。
代码示例
// 好的设计:语义清晰,参数内聚publicclassPlaceOrderCommand{@NotNullprivateLonguserId;@NotNullprivateLongitemId;@Min(1)privateIntegerquantity;privateStringchannel;// 渠道}// 不好的设计:参数散落,无语义Result<OrderDO>checkout(LonguserId,LongitemId,Integerquantity,Stringchannel);Query对象的设计讲究
Query代表调用方明确想查询的数据需求,预期对系统完全不产生副作用(只读操作)。
设计要点
- 封装查询条件:包括过滤条件、分页参数、排序规则等,统一收拢到一个Query对象中。
- 命名体现查询意图:如
OrderListQuery、UserDetailQuery,让人一看就知道查什么。 - 可以省略的情况:当仅通过单一ID查询时,可以不创建Query对象,直接传ID即可。
代码示例
// 好的设计publicclassOrderListQuery{privateLongsellerId;privateLongitemId;privateOrderStatusEnumstatus;privateintcurrentPage;privateintpageSize;}// 不好的设计:一个查询条件一个方法,接口膨胀List<OrderDO>queryByItemId(LongitemId);List<OrderDO>queryBySellerId(LongsellerId,intpage,intsize);List<OrderDO>queryByStatus(OrderStatusEnumstatus);Command vs Query vs DTO 的区别
这是很多人容易混淆的地方:
| 对比维度 | Command / Query | DTO |
|---|---|---|
| 角色 | 应用服务的输入 | 应用服务的输出 |
| 语义 | 携带明确的"意图" | 纯粹的数据容器 |
| 是否包含逻辑 | 可包含基础校验 | 不包含任何逻辑(贫血对象) |
| 数量特征 | 理论上可以无限多,每个代表不同意图 | 通常与展示场景对应 |
应用层服务的设计规范
基于Command和Query的区分,应用服务(ApplicationService)的接口设计应遵循以下规范:
publicinterfaceOrderApplicationService{// Command:写操作,入参是Command对象OrderDTOplaceOrder(@ValidPlaceOrderCommandcommand);// Command:写操作voidcancelOrder(@ValidCancelOrderCommandcommand);// Query:读操作,入参是Query对象List<OrderDTO>queryOrders(OrderListQueryquery);// Query:单一ID查询,可以省略Query对象OrderDTOgetOrder(LongorderId);}关键规则:
- 应用服务的入参只能是一个Command、Query或Event对象(单一ID查询除外)
- 应用服务本身不包含业务逻辑,只负责流程编排
- Command方法不应返回业务数据,Query方法不应修改状态
为什么要这样设计
解决接口膨胀问题
传统写法中,每增加一个查询条件就要新增一个方法,导致接口无限膨胀。用Query对象封装后,新增查询条件只需在Query对象中加字段,接口签名不变。
提升代码的语义表达力
placeOrder(PlaceOrderCommand cmd)比createOrder(Long userId, Long itemId, Integer quantity)更能表达业务意图。代码即文档。
为CQRS架构打下基础
当系统复杂度上升时,Command和Query的分离可以自然演进为物理上的读写分离:
- Command侧:走领域模型,通过聚合根处理业务逻辑,保证ACID
- Query侧:绕过领域模型,直接查询为读取优化的数据源(甚至可以用Elasticsearch等异构存储)
三种CQRS架构方案的选择
根据业务复杂度,可以选择不同层次的CQRS实现:
| 方案 | 存储 | 适用场景 | 复杂度 |
|---|---|---|---|
| 共享存储/共享模型 | 同一数据库、同一模型 | 简单业务,读写需求差异不大 | 低 |
| 共享存储/分离模型 | 同一数据库、不同模型 | 中等复杂度,查询需要扁平化数据 | 中 |
| 分离存储/分离模型 | 不同数据库(如MySQL + ES) | 高复杂度,读写性能要求差异大 | 高 |
实际项目中,大多数团队会优先落地"读写分离版CQRS"(方案2),而不是上Event Sourcing全家桶。如果你的目标是"让系统边界清晰、查询性能更好、代码职责更干净",从读写分离开始通常是更稳妥的选择。
总结
应用层Command和Query的核心讲究可以浓缩为三句话:
- Command表达"意图":封装写操作的全部参数,语义清晰,代表"我要系统做什么"
- Query表达"需求":封装读操作的全部条件,代表"我要看什么数据"
- 两者严格隔离:Command不改返回值,Query不改状态,这条边界一旦守住,系统就能自然地演进到CQRS架构