领域驱动 - 代码层面的命令查询职责分离(CQRS)实践
文章目录领域驱动 - 代码层面的命令查询职责分离(CQRS)实践1. 背景2. 分包设计3. 命令实现4. 查询实现5. 问题与改进6. 演进方向7. 总结领域驱动 - 代码层面的命令查询职责分离(CQRS)实践1. 背景在领域驱动设计DDD中我们通常采用充血模型作为写模型承载复杂的业务逻辑。然而实际业务场景中读模型的需求与写模型存在显著差异数据来源多样一个页面可能需要来自不同领域的数据聚合数据结构灵活读模型需要适配调用方需要的响应结构而非直接暴露充血模型性能考量读操作往往需要更高的灵活性可能涉及复杂的关联查询正是基于这些考虑我们选择在代码层面先实现命令查询职责分离Code-level CQRS为后续架构层面的演进做好准备。2. 分包设计项目采用分层架构关键在于application层按command和query进行分离同时interface层Controller也保持一致的分离风格com.xxx.cqrs/ ├── application/ # 应用服务层 │ └── role/ │ ├── command/ │ │ ├── RoleCommandApplicationService.java │ │ └── impl/ │ └── query/ │ ├── RoleQueryApplicationService.java │ ├── assembler/ │ │ └── RoleAssembler.java │ └── impl/ ├── interface/ # 接口层 │ └── role/ │ ├── command/ │ │ └── RoleCommandController.java │ └── query/ │ └── RoleQueryController.java ├── domain/ # 领域层 ├── acl/ # 防腐层 │ ├── primaryaccount/ │ └── secondaryaccount/ └── api/ # 接口定义层这种分包设计的核心目的是让命令和查询具有独立的演进路径。当未来需要演进到架构层面CQRS时读模型可以独立扩展而不影响写模型。3. 命令实现命令侧专注于写操作通过领域服务完成业务逻辑// 接口定义publicinterfaceRoleCommandApplicationService{voidadd(RoleAddCommandcommand);voidaddPermission(PermissionAddCommandcommand);voidaddAssociated(AssociatedAddCommandcommand);voidremoveAssociated(AssociatedRemoveCommandcommand);}// 实现类核心逻辑Overridepublicvoidadd(RoleAddCommandcommand){// 1. 验证应用存在AppPOappPOappRepository.selectAppByUniqueIdentifier(command.getAppUniqueIdentifier());Assert.notNull(appPO,应用不存在);// 2. 转换为领域BO对象RoleAddBObonewRoleAddBO();bo.setAppId(appPO.getId());bo.setName(command.getName());bo.setPrimaryAccountId(command.getPrimaryAccountId());// 3. 调用领域服务执行业务逻辑domainService.add(bo);}// 领域服务调用充血模型Transactional(rollbackForException.class)publicvoidadd(RoleAddBObo){// 通过工厂创建充血模型RoleroleroleFactory.create(bo);// 调用充血模型的内聚业务逻辑role.add();// 持久化RolePOponewRolePO();po.setUniqueIdentifier(role.getUniqueIdentifier());po.setName(role.getName());po.setType(role.getType().getCode());po.setPrimaryAccountId(role.getPrimaryAccountId());repository.insert(po);}可以看到命令侧做的事情很纯粹接收命令 → 转换为领域BO → 调用领域服务 → 领域服务调用充血模型执行业务逻辑。具体的业务逻辑由领域层Domain Service Entity承载。4. 查询实现查询侧专注于读操作采用Assembler模式完成PO到DTO的转换// 接口定义publicinterfaceRoleQueryApplicationService{ListRoleDTOqueryList(StringappUniqueIdentifier);ListRoleDTOquerySystemBuiltInList(StringappUniqueIdentifier);ListAssociatedDTOqueryAssociated(StringappUniqueIdentifier,LongroleId);}// Assembler模式PO → DTOpublicclassRoleAssembler{publicstaticListRoleDTOtoDTOList(ListRolePOpoList){ListRoleDTOlistnewLinkedList();poList.forEach(po-{RoleDTOdtonewRoleDTO();dto.setId(po.getId());dto.setUniqueIdentifier(po.getUniqueIdentifier());dto.setName(po.getName());dto.setType(po.getType());list.add(dto);});returnlist;}}当查询需要其他领域的数据时通过ACL防腐层获取// 通过ACL获取外部领域数据AutowiredprivatePrimaryAccountFacadeprimaryAccountFacade;AutowiredprivateSecondaryAccountFacadesecondaryAccountFacade;privateLonggetCurrentAccountOwnerId(){LongaccountIdLong.valueOf(AuthContext.getCurrentAccountId());IntegeraccountTypeInteger.valueOf(AuthContext.getCurrentAccountType());if(Objects.equals(accountType,AccountType.SECONDARY_ACCOUNT.getType())){// 通过ACL获取主账号信息PrimaryAccountInfoResultresultprimaryAccountFacade.getBySecondaryAccountId(accountId);returnresult.getOwnerId();}returnaccountId;}ACL层隔离了外部服务的变化使得查询服务可以灵活应对外部系统的调整。5. 问题与改进当前实现存在一个值得思考的问题当读模型需要跨领域数据时应该在哪里聚合在现有设计中聚合逻辑放在了应用服务层RoleQueryApplicationService。这种做法在简单场景下可行但存在不足职责模糊应用服务承担了过多的数据聚合职责复用性差同一份数据在不同场景下可能需要不同的聚合逻辑更合理的做法是由BFFBackend For Frontend层来处理跨领域数据的聚合。BFF层可以根据前端页面的具体需求灵活组装来自不同领域的数据。6. 演进方向当业务发展到一定规模读模型可以进一步演进读/写模型数据库分离写模型保持规范化的业务表结构读模型可以是针对查询场景优化的大宽表BFF的局限实时聚合在数据量不大时工作良好但当数据量大、关联复杂时性能堪忧此时只能采用预聚合方式数据同步方案通过Flink ETL实时清洗业务数据将结果同步到宽表或ES中。读模型直接查询这些针对查询优化的存储获得极佳的查询性能这种演进路径的核心思想是读模型不再受制于写模型的数据结构可以独立优化。7. 总结在代码层面实现命令查询职责分离是为后续架构演进奠定基础的关键一步。通过将命令和查询在应用服务层分离我们可以让写模型保持充血模型的业务表达能力让读模型灵活适配不同场景的数据展示需求为未来读模型的独立演进宽表、ES等提供平滑的迁移路径当然当前实现仍有改进空间如BFF层的职责边界、数据同步策略等这些都需要在后续实践中持续优化。