DDD 系列:项目分包和分层结构 📅 2026/7/24 12:45:11 前面几篇文章中我们陆续学习了实体、值对象、聚合根、应用服务、领域服务、仓储和领域事件。概念单独看都能理解真正开始写项目时却很容易卡住这些类到底放哪个包Controller 能不能直接调用仓储领域层能不能依赖 MyBatis基础设施层是不是只能放工具类这一篇我们就把它们放回一套完整的项目结构中。分层的目的不是让包变多而是让依赖方向和职责边界清楚。如果只是新建几个目录然后所有代码继续互相调用那只是把原来的混乱分散到了更多文件夹里。一、先确定四层职责常见的 DDD 项目可以分成用户接口层、应用层、领域层和基础设施层。层级主要职责常见内容用户接口层接收外部请求并转换协议Controller、RPC、消息消费者、Request、Response应用层编排一次完整用例ApplicationService、Command、Query、事务领域层表达核心业务模型和规则聚合、实体、值对象、领域服务、仓储接口、领域事件基础设施层实现技术能力和外部访问Repository 实现、Mapper、DO、MQ、HTTP Client、配置这四层不是简单的调用顺序更重要的是依赖方向。领域层位于业务核心不应该依赖 Spring MVC、MyBatis、消息队列客户端等具体技术。应用层依赖领域层完成用例基础设施层实现领域层声明的接口用户接口层再把外部协议转换成应用层能够理解的参数。二、按业务模块分包而不是按技术类型堆放很多项目最开始是下面这种结构com.example.mall ├─ controller ├─ service ├─ mapper ├─ entity └─ config项目小时很直观但模块多起来后service目录里可能有几百个类。想看订单业务要在五六个顶级目录之间来回跳。DDD 项目更适合先按业务边界拆分再在模块内部按职责分层com.example.mall ├─ order │ ├─ interfaces │ ├─ application │ ├─ domain │ └─ infrastructure ├─ inventory │ ├─ interfaces │ ├─ application │ ├─ domain │ └─ infrastructure └─ shared这样打开order就能看到订单上下文的完整实现订单和库存之间的边界也更明显。优先按业务能力聚合代码再用分层约束模块内部职责。这比全局建立一个巨大的domain、application、infrastructure目录更适合中大型项目。三、订单模块的完整目录示例下面是一套单体应用内的参考结构order ├─ interfaces │ ├─ web │ │ ├─ OrderController.java │ │ ├─ request │ │ └─ response │ └─ message │ └─ PaymentCompletedConsumer.java ├─ application │ ├─ command │ │ ├─ CreateOrderCommand.java │ │ └─ PayOrderCommand.java │ ├─ query │ │ └─ OrderPageQuery.java │ ├─ service │ │ ├─ OrderApplicationService.java │ │ └─ OrderQueryService.java │ └─ assembler │ └─ OrderApplicationAssembler.java ├─ domain │ ├─ model │ │ ├─ Order.java │ │ ├─ OrderItem.java │ │ ├─ OrderId.java │ │ ├─ Money.java │ │ └─ OrderStatus.java │ ├─ service │ │ └─ OrderPricingDomainService.java │ ├─ repository │ │ └─ OrderRepository.java │ └─ event │ └─ OrderCreatedEvent.java └─ infrastructure ├─ persistence │ ├─ repository │ │ └─ MyBatisOrderRepository.java │ ├─ mapper │ │ ├─ OrderMapper.java │ │ └─ OrderItemMapper.java │ ├─ dataobject │ │ ├─ OrderDO.java │ │ └─ OrderItemDO.java │ └─ converter │ └─ OrderDataConverter.java ├─ messaging │ └─ SpringDomainEventPublisher.java └─ client └─ PaymentGatewayClient.java目录不是固定答案可以按团队习惯简化。但每个类放在哪里应该能说清楚业务理由。四、请求怎么穿过这些层以支付订单为例请求链路可以写得很清楚。1. 用户接口层接收协议RestControllerRequestMapping(/orders)publicclassOrderController{privatefinalOrderApplicationServiceorderApplicationService;/** * 接收 HTTP 请求并转换成应用命令。 * * Controller 只处理协议参数和响应格式不直接修改订单状态也不调用 Mapper。 */PostMapping(/{orderNo}/pay)publicvoidpay(PathVariableStringorderNo,RequestBodyPayOrderRequestrequest){PayOrderCommandcommandnewPayOrderCommand(orderNo,request.paymentNo());orderApplicationService.pay(command);}}2. 应用层编排用例ServicepublicclassOrderApplicationService{privatefinalOrderRepositoryorderRepository;/** * 完成订单支付用例。 * * 应用层负责加载、调用和保存具体支付规则由订单聚合自身维护。 */Transactionalpublicvoidpay(PayOrderCommandcommand){OrderorderorderRepository.findByOrderNo(command.orderNo()).orElseThrow(()-newIllegalArgumentException(订单不存在));order.pay(command.paymentNo());orderRepository.save(order);}}3. 领域层执行规则publicclassOrder{/** * 支付订单。 * * 只有待支付订单才能支付外部代码不能绕过该方法直接设置状态。 */publicvoidpay(StringpaymentNo){if(status!OrderStatus.PENDING_PAYMENT){thrownewIllegalStateException(当前订单状态不允许支付);}this.paymentNopaymentNo;this.statusOrderStatus.PAID;}}4. 基础设施层完成存储仓储实现把领域对象转换成 DO再调用 Mapper。应用层和领域层不需要知道数据库表怎么设计。到这里调用方向是清楚的接口层调用应用层应用层调用领域模型和仓储接口基础设施层实现仓储接口。五、基础设施层为什么可以依赖领域层很多人第一次看这套结构会觉得奇怪应用层调用OrderRepository实现类却在基础设施层这不是反过来了吗其实这里使用的是依赖倒置。领域层声明“我需要一个订单仓储”基础设施层提供“我用 MyBatis 实现这个仓储”运行时由 Spring 把实现注入应用服务。业务核心定义接口技术细节实现接口。这样数据库框架依赖业务而不是业务依赖数据库框架。六、哪些内容不要放进 sharedshared、common很容易变成新的垃圾桶。适合共享的通常是非常稳定、没有特定业务归属的内容例如统一异常基类、分页结构、时钟接口。订单状态、会员等级、优惠规则等业务概念不应该因为“多个地方要用”就直接丢进公共包。跨上下文共享业务对象会让两个模块重新耦合。更稳妥的方式是通过业务编号、接口契约或集成事件协作。只有真正稳定且无业务归属的能力才进入共享模块。拿不准时先留在业务模块里。七、一定要拆成多个 Maven 模块吗不一定。分层首先是代码职责和依赖方向多 Maven 模块只是强化边界的一种手段。项目较小时可以先在一个 Spring Boot 模块中按包分层当团队、构建或复用边界确实需要隔离时再拆模块。常见的多模块方式如下mall-order ├─ mall-order-domain ├─ mall-order-application ├─ mall-order-infrastructure └─ mall-order-interfaces拆分后可以通过 Maven 依赖直接限制层级但也会增加构建、配置和依赖管理成本。不要为了目录看起来像 DDD就把一个小项目拆成十几个模块。八、怎么验证依赖方向先做最简单的静态检查domain下不应 import Controller、Mapper、DO 和具体 MQ 客户端application下不应直接 import Mapper 和数据库 DOinterfaces不应直接修改聚合内部字段infrastructure可以依赖领域接口但不应反过来被领域实现调用。项目稳定后可以使用 ArchUnit 把规则写成测试TestvoiddomainShouldNotDependOnInfrastructure(){noClasses().that().resideInAPackage(..domain..).should().dependOnClassesThat().resideInAPackage(..infrastructure..).check(importedClasses);}看到规则测试通过只能说明包依赖没有越界业务逻辑是否放对位置还要结合代码阅读和领域测试判断。九、总结这一篇把 DDD 项目的四层结构和分包方式串了一遍。先按业务边界组织模块再在模块内部区分接口、应用、领域和基础设施领域层保持业务纯粹技术实现通过依赖倒置接入。目录不需要照抄Maven 模块也不必一步拆满。只要职责清楚、依赖方向稳定结构就是为业务服务的。下一篇继续解决一个特别容易混乱的问题DTO、VO、DO 和 Entity 到底分别放在哪里又应该在哪一层完成转换。