ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

领域驱动设计四层架构入门:从实体、聚合到实战落地

领域驱动设计四层架构入门:从实体、聚合到实战落地 大学毕业那会儿第一次读《领域驱动设计》说实话前两章就把我劝退了。后来在一家传统企业做核心系统重构被逼着用 DDD 梳理了三个月业务才慢慢摸到门道。今天想把自己折腾出来的理解整理一下不谈晦涩的理论就说说四层领域模型到底是什么里面那些基础概念在实战中到底怎么用。很多人一上来就盯着四层两个字觉得不就是 Controller、Service、Repository 分个层嘛。真不是这么回事。DDD 的核心是让业务语义贯穿整个系统而不是把代码按照技术职责切几刀。你按三层架构写出来的代码业务逻辑散落在 Service 里领域对象退化成只有 getter/setter 的数据类项目一复杂就成了一锅粥。这正是 DDD 想解决的问题。1. 四层领域模型的核心结构DDD 的四层架构从上到下依次是接口层Interface、应用层Application、领域层Domain、基础设施层Infrastructure。很多文章会把四层画成同心圆或者上下堆叠的方块但无论怎么画依赖方向只有一个从上往下、从外向内依赖领域层不依赖任何外层。1.1 接口层把外界翻译成领域接口层说白了就是系统的门面。它负责接收 HTTP 请求、MQ 消息、定时任务触发、外部 RPC 调用等然后把外部输入翻译成应用层能听懂的语言。这里有个新手常犯的错误直接把实体对象丢给前端当 DTO 用。去年接手过一个订单系统前端要展示订单信息后端直接把Order实体序列化返回结果订单状态的枚举值、金额的精度、时间的格式全都暴露给了前端前端还时不时发现多了一个不该暴露的字段。后来我把接口层迁到独立的 DTO 上实体彻底从 HTTP 层消失这种问题才消停。接口层的职责就三件事参数校验格式、必填项、基础合法性将请求参数组装为应用层需要的输入对象Command/Query将应用层返回的结果转换为前端需要的 DTO 并响应1.2 应用层编排业务流程但不承载业务规则应用层是很多人误解最深的一层。它看起来像旧的 Service 层但有一条铁律业务规则不能在应用层里写。我见过一个支付模块的代码TransferService里写了几十行余额是否充足、单笔限额、日累计限额、风控校验——这些全是业务规则应该属于领域层。应用层该做什么它应当只做用例编排找到对应的领域服务或聚合根调用它们的方法把结果组装回去必要时协调多个领域服务完成一次完整的事务。打个比方应用层像餐厅里的大堂经理知道客人坐下→点菜→下单→上菜→结账的流程但哪些菜不能搭配由后厨领域层说了算大堂经理无权更改。一个典型的应用层方法长这样public class OrderAppService { private final OrderRepository orderRepository; public PlaceOrderResult placeOrder(PlaceOrderCommand command) { // 1. 从仓储取出领域对象 Order order orderRepository.findByOrderId(command.getOrderId()); // 2. 调用聚合根或领域服务执行业务操作 order.place(command.getItems()); // 3. 保存状态变更 ListDomainEvent events orderRepository.save(order); // 4. 发布领域事件 eventPublisher.publish(events); return PlaceOrderResult.from(order); } }注意方法里没有余额是否充足库存是否够这种判断逻辑——这些在order.place()内部由领域模型自己做。1.3 领域层系统的灵魂领域层是四层架构的核心也是最难设计的一层。它包含实体、值对象、聚合、领域服务、领域事件、仓储接口等元素。服务和应用层可以烂一点领域层如果设计歪了整个项目的业务表达就完了。领域层的核心思想是把业务规则放进模型里让模型自己约束自己的状态变化。比如一个订单的cancel()方法必须自己检查已发货的订单不允许取消而不是在 Service 层通过 if 判断。1.4 基础设施层最不业务的一层基础设施层是工具层负责实现领域层声明的仓储接口、数据库访问、消息发送、缓存操作等。它依赖领域层的接口但不被领域层反向依赖。很多人有个误区觉得基础设施层是最没技术含量的一层。实际上这一层决定了系统能不能平稳运行。MyBatis 的 Mapper、Redis 的存取、MQ 的消息投递都在这层实现。早期项目里把 SQL 写在 Service 层的事我没少干后来强制把数据访问收敛到基础设施层代码整洁度提升了一个档次。这里有张表总结四层的职责与依赖关系层级核心职责能依赖谁典型产物接口层输入输出翻译应用层Controller、DTO、MessageListener应用层用例编排、事务领域层AppService、Command/Query领域层业务规则、核心逻辑自己Entity、ValueObject、Aggregate、DomainService基础设施层技术实现领域层接口、外部框架RepositoryImpl、MQProducer、HttpClient2. 领域层的基础概念逐个过2.1 实体Entity有唯一标识、会改变的对象实体的特征是拥有唯一标识ID和生命周期。两个属性完全相同的对象只要 ID 不同就是两个不同的实体。比如两个人名字一样、年龄一样但身份证号不同就是两个实体。实体的关键是封装业务规则public class Order { private OrderId orderId; private OrderStatus status; private Money totalAmount; public void cancel() { if (this.status OrderStatus.SHIPPED || this.status OrderStatus.COMPLETED) { throw new IllegalStateException(已发货或已完成的订单不能取消); } this.status OrderStatus.CANCELED; } }这段代码把不能取消已发货订单这条规则放进了实体内部。外部调用方不需要知道这条规则实体自己保证状态变化合法。2.2 值对象Value Object描述性概念无标识值对象没有唯一标识只描述事物的属性。比如金额、地址、颜色。public class Money { private final BigDecimal amount; private final String currency; public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException(币种不一致); } return new Money(amount.add(other.amount), currency); } }值对象最大的价值在于不变性Immutable。金额一旦创建就不能修改所有操作都返回新对象。这能避免大量setter带来的隐式 bug。2.3 聚合Aggregate保证一致性边界聚合是一组实体的组合由聚合根统一对外暴露操作。聚合内部的对象可以互相访问外部只能通过聚合根访问。订单聚合通常包含Order聚合根、OrderItem、Address。外部代码不能直接得到OrderItem然后该它的数量必须通过Order的某个方法操作。这保证了订单维度的一致性。我踩过一个坑订单和明细用了两个仓储明细的操作散落在 Service 里结果经常出现主订单状态是已支付明细里却找不到任何商品的数据不一致。后来把明细收敛到订单聚合内部通过订单根来管理明细这种问题基本绝迹。2.4 领域服务Domain Service处理不适合放在实体里的逻辑有些业务逻辑涉及多个对象塞进任何一个实体里都不合适。比如计算两个账户之间的转账金额涉及账户 A 和账户 B这时候适合用领域服务public class TransferService { public void transfer(Account source, Account target, Money amount) { source.debit(amount); target.credit(amount); } }判断标准很简单如果逻辑放实体里会让实体承担不该有的职责就抽出来放领域服务。但别滥用领域服务应该只承载跨实体的行为协调而不是把所有逻辑一股脑丢进去。2.5 领域事件Domain Event捕获业务中发生的事领域事件是 DDD 中比较靠后引入的概念主要用于解耦聚合间的通信。订单支付成功后发布OrderPaidEvent库存服务监听这个事件做扣减通知服务监听它发短信。public class OrderPaidEvent implements DomainEvent { private final OrderId orderId; private final Instant occurredAt; }事件发布让系统从同步强耦合走向异步解耦但也引入了最终一致性的问题。对数据一致性要求极高的场景别盲目用事件。2.6 仓储Repository领域层声明的数据访问接口仓储接口属于领域层实现属于基础设施层。领域层定义怎么查怎么存的意图基础设施层负责用 MyBatis 还是 JPA 实现。public interface OrderRepository { Order findById(OrderId orderId); void save(Order order); }仓储和 DAO 的区别在于DAO 偏向表结构仓储偏向聚合的持久化。仓储负责把一个完整聚合重新组装起来或者把改变后的聚合完整存回去。3. 四层之间的协作流程用一个下单的例子串一下完整流程从用户请求到数据落库四个层各自干了什么。第一步用户发起POST /orders请求到达接口层的OrderController。Controller 校验参数格式后组装成一个PlaceOrderCommand再调用应用层的orderAppService.placeOrder(command)。第二步应用层拿到 command 后从订单仓储里根据 ID 取出Order聚合根调用order.placeOrder(items)方法。这个方法内部完成库存校验计算金额设定状态产生领域事件。第三步应用层将订单保存回仓储。仓储的实现基础设施层把订单和明细拆成多张表进行持久化发布领域事件到消息队列。第四步消息队列把OrderPlacedEvent推给库存服务、通知服务等其他模块。整个流程中数据流向是从外到内接口层 → 应用层 → 领域层 → 基础设施层再转出去持久化。4. 四层架构和经典三层/六边形架构有什么区别“DDD 的四层领域模型”热搜常和六边形架构一起出现。不少人对它们概念混淆这里说清楚。传统三层架构分为 Web、Service、DAO 三层。它的默认方向是横向分层所有层都直接依赖数据访问层。DDD 四层强调领域层是核心其他层都围绕它转。六边形架构端口与适配器架构是对四层架构的一种演进。外部世界通过适配器Adapter与端口Port交互把一个系统的输入与输出摆在等同位置更加强调应用边界的隔离。四层架构解决的是领域逻辑与技术实现分离的问题六边形架构解决的是应用与外部依赖隔离的问题。如果你在做微服务我建议直接参考六边形架构的思路来落地 DDD 四层领域层在最中间仓储等端口放在边缘适配器Controller、Consumer、外部 API Client在最外层。这样既有了四层的业务分层思维又获得了六边形架构的高可测试性和可替换性。5. 实战中常见的坑和对应的处理方式5.1 缓存、参数校验到底放哪一层参数校验分两层格式校验在接口层语义校验在应用层或领域层。比如订单 ID 不能为空是格式校验放在接口层该订单已超过可取消时间是业务规则放在领域层。缓存处理也分两种只读缓存如查商品信息放在基础设施层因为它是技术实现细节业务性缓存如账户实时余额放领域层做判断避免从缓存中取到脏数据后做出错误决策。5.2 事务应该开在哪一层事务边界应该开在应用层。一次用例 一次事务。如果把事务放到领域层领域方法容易过度依赖事务环境放到接口层则事务粒度太粗一个请求涉及多个用例时会出问题。5.3 领域对象被层层传递的问题很多团队把Order实体直接从 Controller 传到 AppService、传到领域服务、再传回 Controller这种操作会让实体和 UI 高度耦合。我的习惯是Controller 和 AppService 之间传递 Command/DTOAppService 和领域层之间传递实体。边界分明谁也不会越界。5.4 过度设计DDD 不是银弹。一个简单 CRUD 后台管理系统硬要拆出四层、定义值对象、设计领域事件最后只会拖慢开发速度。判断标准很简单当业务规则复杂到用三层架构难以维护时才值得引入 DDD。我在实际工作中发现绝大多数团队是在业务逻辑确实复杂的订单、支付、库存这类系统上才真正感受到 DDD 的价值。写到这里想起当时啃 DDD 时的一个体会这四层架构表面上是代码分层骨子里是认知分层——它逼着团队先想清楚什么是业务规则再谈怎么实现。如果你正在重构成这种架构我建议从订单或支付这类“业务规则密集且不断变化”的模块入手练手先别一步到位。把一个模块的实体、值对象、仓储梳理清楚感受一下业务规则内聚带来的维护体验差异再逐步推广到更多模块。真正消化了聚合的边界和依赖方向之后你会回来感谢这个逼你动脑子的架构风格。
返回列表