DDD架构与对象设计:从贫血模型到聚合根的实战指南

DDD架构与对象设计:从贫血模型到聚合根的实战指南 从最初接手那套“千层饼”一样的订单系统到后来带团队按照领域驱动设计DDD重新梳理核心链路这中间踩过的坑比看过的理论书多得多。老实说市面上讲 DDD 的书和文章不少但大多停在“什么是聚合、什么是限界上下文”的概念层真正到了写代码的时候很多人还是不知道怎么把“原则”变成一个个具体的“对象”也不知道该让哪个类负责哪件事。这篇文章我不打算复读教科书而是围绕“DDD 架构与对象”这条主线结合真实项目经历拆解从原则到实践的完整路径。适合看这篇文章的人一方面是被网上各种 DDD 海报和架构图搞得一头雾水、想在代码层面真正理解它的开发者另一方面是已经在用 DDD 但总觉得代码别扭、想回头审视自己建模和对象设计逻辑的团队。我会把核心对象实体、值对象、聚合、分层架构、仓储落地、领域服务与应用服务的边界这些内容揉开讲并且尽量说“为什么这样设计”而不是只丢结论。1. DDD 不是银弹先想清楚它到底解决什么问题刚开始接触 DDD 的人最容易把它当成一套“用了就能解决所有架构问题”的终极方案。我见过不少团队一上来就把项目从三层架构强行改成四层架构包名全部换成domain、infrastructure然后宣称“我们在搞 DDD”。折腾几个月之后代码比原来更绕交付效率反而更低。问题的根源不在于 DDD 本身而在于没搞清楚它到底为了解决什么问题、适合什么场景。1.1 业务复杂度的临界点什么时候该上 DDD很多团队是在业务逻辑逐渐失控的时候才想起 DDD。典型症状是一个 Service 类里堆了几千行代码一个订单状态流转的方法里塞了十几个 if 分支任何一次需求变更都要小心翼翼因为你根本不知道改这一处会影响哪些地方。这种代码的普遍特征是“事务脚本Transaction Script”——每个方法就是一条流程脚本从参数校验、状态判断、数据更新到调用外部接口全部串在一个方法里。事务脚本本身并不是坏东西在业务逻辑简单、变化不频繁的场景下它非常高效甚至比强行引入 DDD 更快。但一旦业务规则复杂起来比如保险定价策略、财务对账、供应链调度这类领域逻辑密集的系统事务脚本的缺陷就暴露了业务规则散落在各个 Service 方法中无法被复用也无法被清晰表达改一个规则可能要翻十几个文件。DDD 的适用场景恰恰是这里的“复杂领域逻辑”当你发现核心业务规则足够复杂复杂到需要一种方式把知识显性化、把规则集中沉淀下来时才值得引入 DDD。这也是 DDD 中“领域”二字的含义——它关心的是业务专家脑子里的那套规则而不是技术框架怎么分层。如果你的系统只是简单的增删改查数据表几十张、接口几百个、逻辑大多是 CRUD 拼接老实说DDD 带来的复杂度会超过它解决的问题。个人经验是核心业务模块比如订单、库存、结算上 DDD边缘模块用传统方式比“一刀切全员 DDD”要务实得多。1.2 DDD 的核心思想让对象承载规则而不是让流程操作数据DDD 有几个关键概念很多文章要么堆概念要么只讲单词。我习惯用一句话概括DDD 的核心思想是让业务规则住进对象内部而不是漂浮在 Service 方法里。举个例子一个“订单取消”的业务规则。在传统事务脚本风格下你可能这样写public class OrderService { public void Cancel(long orderId, string cancelReason) { var order _orderRepository.GetById(orderId); if (order.Status ! OrderStatus.PendingPayment order.Status ! OrderStatus.Paid) throw new BizException(当前订单状态不允许取消); order.Status OrderStatus.Cancelled; order.CancelReason cancelReason; order.CancelTime DateTime.Now; _orderRepository.Update(order); } }这段代码看起来没问题但你有没有发现关于“什么状态的订单能取消”这个规则根本不存在于 Order 对象里而是存在于 OrderService 方法里。如果另一个地方也有类似的取消动作比如管理员强制取消、超时自动取消它就必须把这段 if 逻辑再复制一遍。这还没完假如业务规则变成“已发货订单取消需经过审核”你就要去所有写取消逻辑的地方逐一修改漏改一处线上就出问题。DDD 的做法是把状态判断内聚到领域对象里public class Order : AggregateRoot { public OrderStatus Status { get; private set; } public string CancelReason { get; private set; } public void Cancel(string reason) { if (Status ! OrderStatus.PendingPayment Status ! OrderStatus.Paid) throw new BizException(当前订单状态不允许取消); Status OrderStatus.Cancelled; CancelReason reason; CancelTime DateTime.Now; AddDomainEvent(new OrderCancelledEvent(Id, reason)); } }同样一个动作规则从 Service 移到了领域对象内部。Service 只需要调用order.Cancel(用户主动取消)然后交给仓储保存。这样做最大的好处是业务规则被封装在它本该存在的地方任何需要取消订单的场景都必须经过 Order 对象规则不会被绕开。我经常拿“助理和专家”的类比来解释这种区别。事务脚本是助理什么流程都会帮你跑一遍但真正的“专业知识”在助理脑袋里是碎片化的DDD 里的聚合根则像是领域专家你说“取消订单”专家自己就知道什么情况能取消、取消后要做什么而不是你教他每一步怎么走。这个思维转变对写代码的人来说往往是第一道坎。2. 领域对象的前世今生从失血模型到充血模型理解了“让对象承载规则”这个方向接下来要解决的就是具体问题一个对象里到底该放什么不该放什么状态和行为应该怎么组织这一节我会从对象设计的历史演进讲起因为很多人对贫血模型、充血模型只停留在一个“听说过”的层面不知道怎么判断、怎么落地。2.1 贫血模型为什么会让 DDD 名存实亡所谓贫血模型Anemic Domain Model指的是领域对象只有属性、只有 getter/setter没有任何业务行为。比如public class Order { private Long id; private Integer status; private BigDecimal totalAmount; private String cancelReason; public Long getId() { return id; } public void setId(Long id) { this.id id; } public Integer getStatus() { return status; } public void setStatus(Integer status) { this.status status; } public BigDecimal getTotalAmount() { return totalAmount; } public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount totalAmount; } // ... }这种对象在 Java 生态里太多了。你为了“分层清晰”建了一个domain包里面却全是这种只有数据的 POJOPlain Old Java Object。所有规则都在 Service 层写放在哪个 Service 就由调用方的习惯决定结果就是服务层越来越臃肿领域层形同虚设。名字叫 DDD玩出来的却是典型的“事务脚本 数据模型”。很多人会问为什么贫血模型会这么普遍一个重要原因是思维惯性。从三层架构时代开始我们的习惯就是把“模型”当作数据传输的工具对象就是对应数据库表结构Service 才是处理逻辑的地方。另一个原因是框架推力比如 Spring Data JPA 和 MyBatis 都鼓励你从表结构反推对象久而久之就把“领域对象”等同于“数据实体”。但贫血模型带来的问题是深远的。试想一个“订单总金额计算”的规则如果涉及折扣、税费、运费多个因素基于贫血模型的写法往往是在 Service 里一步步调一堆 getter 去算规则分散又难以测试。而充血模型Rich Domain Model则把计算行为放进对象里外部只需要调用order.calculateTotal()规则变了只改这一处。2.2 充血模型的设计要点状态与行为的一致性充血模型的核心是保持对象状态与行为的一致性——也就是说对象的所有状态变更必须经过对象自己的方法来完成不允许外部随意 set。回到上面的例子setStatus(1)这种代码在充血模型里是不允许出现的状态变更必须通过Cancel()、Confirm()、Ship()这类业务方法来触发。这样对象内部的规则就能一直生效。设计充血模型时有几个实操要点值得单独说私有 setter对外部隐藏状态字段的写操作所有状态变更入口收敛到业务方法。不变量检查在状态变更方法内先检查前置条件不满足就抛领域异常。事件记录状态变更时记录领域事件Domain Event供其他模块异步响应而不是直接在对象里调远程服务。持久化隔离对象自己不关心怎么存它只负责业务规则持久化交给仓储。有人担心“充血模型会导致对象里塞太多东西”这确实是新手容易走偏的另一个方向。一个订单对象里既写状态流转、又写折扣计算、还写消息通知、再写日志记录那也是一个上帝对象比贫血模型更糟糕。所以下一节必须重点讲怎么划分对象怎么界定每个对象该装什么。3. 核心对象的划分与边界实体、值对象、聚合的实战判别DDD 里面最容易被误解、也最影响代码质量的就是对实体Entity、值对象Value Object和聚合Aggregate这三个基本对象类型的判断。很多团队之所以最终代码混乱根源就在这一步没想清楚就开始写。我在实际项目中总结了一套相对实用的判断方法不一定跟书本完全一致但落地效果好。3.1 实体和值对象看它有没有“身份”实体和值对象是两种基本对象类型判断标准其实非常朴素一个东西是否需要被单独追踪身份需要它大概率是实体不需要它更可能是值对象。举一个典型的例子订单里的“收货地址”。在多数业务系统里收货地址通常被建模成实体有一张 address 表、有主键 id、有各种字段。但认真想想附件地址有“身份”吗系统里关心“这个地址是谁吗”只要地址内容相同它跟另一个地址就没有本质区别。它是典型的臣服于订单的“值对象”。如果你的业务中需要反复修改同一个地址、追踪地址的历史再把它建模成实体也不迟。但绝大多数电商场景把地址建模成值对象会简单很多public class Address { private final String province; private final String city; private final String detail; // 构造器、equals、hashCode... }值对象最重要的特性是不可变、靠值相等来判断相等。正因为不可变它可以被多个对象安全共享不用担心被意外修改。我经常跟团队说如果你发现自己给一个值对象写了 update 方法先停下来想一想为什么不能直接替换成新对象地址变了就传一个新的 Address 对象给订单而不是去改旧对象内部的状态。这个思维对保证数据一致性很有帮助。实体的特征则相反它有唯一标识id它在整个生命周期里即使属性全变了“它是它”的事实依然不变。比如一个人的手机号变了他还是同一个人因为身份没变。实体的身份是持久的而属性是变化的。建模时实体的重点在于稳定标识和生命周期管理比如订单就是一个典型实体订单号就是它永恒的身份。3.2 聚合一致性边界才是真正的划分依据很多教程对聚合的解释停留在“一组相关对象的集合”这种层面但“相关”这个词说得太模糊了。实践里我判断聚合只有一个核心标准事务一致性边界。也就是说什么样的一组对象在业务规则上必须作为一个整体来保存、更新它们就应该属于同一个聚合。拿经典的订单-订单项关系来说。订单项的总金额必须从属于订单订单的状态变化会直接影响订单项如果允许订单项脱离订单单独存取很容易出现“订单改成了取消订单项还在库存里占着”这种不一致。把订单当成聚合根订单项作为聚合内部的子实体所有对订单项的访问都必须通过订单这个“入口”才能保证事务边界内的数据强一致。在代码层面聚合有几个强制设计要求聚合根是外部访问聚合的唯一入口外部只能拿到聚合根的引用不能直接拿内部实体去修改。聚合内部的对象引用只能从聚合根出发不允许外部把内部实体的引用再塞回其他地方。聚合内部事务一致聚合之间最终一致——这是很多刚入门的人最难接受的一点。以订单为例下单后要扣库存严格来说订单和商品库存是两个聚合不能把库存写进订单聚合里用一个数据库事务强一致地处理而是发布领域事件由库存限界上下文消费事件做最终一致。我把聚合的集合逻辑做个简化对比方便大家对照判断维度实体值对象聚合有没有唯一身份有无聚合根有内部对象可无可变性可变不可变聚合根可变内部受控是否可共享不建议随意共享可安全共享不允许跨聚合直接引用内部典型例子订单、用户、发票金额、地址、时间段订单含订单项3.3 事务边界和业务能力从业务场景反推聚合设计关于聚合的设计还有一个很容易踩坑的点聚合不能设计得太大也不能设计得太小。聚合太大比如把订单、商品、库存、物流全部塞进一个聚合表面上是“强一致”了实际上系统性能会急剧下降所有写入都要在一个事物里锁一大片表并发一高系统根本扛不住。聚合太小把可信任一个共享值对象的订单项和订单拆成两个聚合又会导致一致性没法保障。我的经验是从一个具体业务场景反推先明确这个业务场景中为了完成一次操作哪些数据必须被同时修改、同时保持一致这些必须强一致的数据就该在一个聚合内。其他只做查询、只做引用的数据一律放到聚合外。比如“用户修改个人地址”这个场景必须同时修改的是用户的基本信息和收货地址列表那它们就在同一个聚合里“下单”这个场景必须强一致的是订单头、订单项、订单金额商品库存则不必在同一个事物里强一致所以库存是另一个聚合。用这种方式反复推演聚合边界会越来越清晰而不是靠拍脑袋决定“谁跟谁绑定”。4. 从原则到落地领域层、应用层和基础设施层的职责与协作对象层面想清楚了接下来就是架构层面的事情。DDD 分层架构中的四层——用户接口层User Interface / API、应用层Application、领域层Domain、基础设施层Infrastructure——看起来简单实际项目中最容易出问题的是领域层和应用层的边界。很多人把应用服务当成了康庄大路什么逻辑都往里塞结果领域层又变成了摆设。这里我展开说每一层到底该干什么。4.1 领域层只放业务规则不放流程编排领域层是 DDD 的核心也是业务规则的家。它包含实体、值对象、聚合根、领域服务Domain Service和领域事件。这里有一条容易被忽略的铁律领域层不依赖任何技术框架或基础设施。它不应该知道“我是用 MyBatis 从 MySQL 读的数据”也不该关心“我这个数据最终怎么变成 JSON 返回给前端”。所有的业务规则应该可以脱离 Spring/Java EE 等框架独立测试这也是领域层代码可测性高的根源。领域服务Domain Service解决的是“这个逻辑放哪个对象里都不合适”的情况。比如“跨订单的冲突检测”或者“分润计算”它涉及多个聚合、多个实体但仍然是业务规则不是流程编排。这种情况下可以写一个领域服务public class CommissionCalculator { public Commission calculate(Order order, Distributor distributor) { // 分润计算的核心公式在这里 } }注意领域服务里的输入是领域对象输出的也是领域对象或值对象它不碰数据库不碰远程调用也不管事务怎么开。这些都是应用层或基础设施层的职责。4.2 应用层做协调者做事不决策应用层是领域层与外部世界的桥梁。它接收用户请求协调一个或多个领域对象完成业务用例但应用服务本身不应该包含任何业务规则和业务决策。换句话说应用层知道“做什么、按什么顺序做”但“怎么做、什么能做”要问领域层。以一个“创建订单”的用例为例应用服务的职责是构造 Order 聚合所需的基础数据比如从请求参数组装 Address 值对象。调用 Order 工厂或构造函数生成订单对象。调用仓储保存订单。发布领域事件通常由事件总线异步分发。处理事务边界通常加在应用服务的方法上。这些步骤里没有一步需要应用服务自己判断“订单能不能创建”判断逻辑都在 Order 聚合根的构造器或工厂方法里。这样设计出来的应用服务代码非常薄可读性高、容易测试这也是很多人说的“用例编排”。4.3 基础设施层把技术细节关在门外基础设施层包括数据库访问、缓存、消息队列、文件存储、外部服务调用等。在 DDD 中它的一个重要职责是定义和实现仓储接口。领域层定义仓储接口如OrderRepository基础设施层负责实现比如使用 Spring Data JPA 实现JpaOrderRepository实现细节完全隔离在领域层之外。这正是依赖倒置原则的体现高层模块不依赖低层模块两者都依赖抽象。实际项目里一些团队会在领域层引入 MyBatis 或 Hibernate 的注解导致领域对象被技术框架污染。这不是说绝对不行但代价是领域层的可移植性和可测试性下降换数据源的时候要改领域代码。我更推荐用纯 POJO 定义领域对象用独立的持久化对象PO和 Mapping 机制把领域对象转成数据库结构。虽然多写一层转换代码但换数据库或换框架时领域层完全不用动。对于团队规模小、迭代速度快的项目直接从领域对象映射数据库表也未尝不可只要理解代价、接受即可。4.4 一个完整用例的分层跑通创建订单光说理论容易飘我用一个最简单的“创建订单”用例把四层串一遍。假设前端请求参数是{ userId: 10001, items: [ { skuId: A001, quantity: 2 }, { skuId: A002, quantity: 1 } ], address: { province: 浙江省, city: 杭州市, detail: 某某大厦 18 层 } }用户接口层接收 HTTP 请求把 JSON 转成应用层需要的参数对象DTO调applicationService.createOrder(...)。这一层不参与任何业务判断只做参数格式适配和响应结果转换。应用层public class OrderApplicationService { private final OrderRepository orderRepository; private final ProductRepository productRepository; private final DomainEventPublisher eventPublisher; Transactional public CreateOrderResult createOrder(CreateOrderCommand command) { // 1. 查询商品快照组装聚合所需数据 ListProductBrief products productRepository.findByIds(command.getItemSkuIds()); // 2. 构建值对象 Address address new Address(command.getProvince(), command.getCity(), command.getDetail()); // 3. 创建聚合根 Order order Order.create(command.getUserId(), products, address); // 4. 保存 orderRepository.save(order); // 5. 发布事件 eventPublisher.publish(order.popEvents()); return CreateOrderResult.from(order); } }领域层Order.create这个聚合根工厂方法内部会校验商品是否可售、计算金额、生成订单号public class Order extends AggregateRoot { public static Order create(Long userId, ListProductBrief products, Address address) { if (products null || products.isEmpty()) { throw new BizException(订单项不能为空); } // 校验商品上下架状态、库存数量等 // 计算总金额、优惠金额、应付金额 Order order new Order(); order.userId userId; order.address address; order.items products.stream() .map(p - new OrderItem(p.getSkuId(), p.getPrice(), p.getQuantity())) .toList(); order.calculateTotal(); order.addDomainEvent(new OrderCreatedEvent(order.id)); return order; } }基础设施层OrderRepository的实现负责把 Order 聚合持久化到数据库可能需要把聚合一拆为多表order、order_item保存。这个例子虽然简单但已经能完整看到各层的边界业务规则校验、计算在领域层流程编排查商品、存订单、发事件在应用层技术访问数据库、事件分发在基础设施层。如果你写的应用服务比这个复杂得多比如里面有大量 if/else 判断某状态能不能执行某个操作那大概率规则放错层了。5. 架构落地中的坑领域模型的序列化、持久化与事务处理即使理解了三层职责真正把 DDD 落地到实际项目时还会有不少容易被忽视的坑。这些坑我全都踩过分布在序列化、持久化、事务三个最日常的环节。下面结合具体场景展开。5.1 领域对象的序列化别让 JSON 污染领域层一个很常见的问题领域对象能不能用 Jackson 注解能不能把它直接序列化后发到消息队列我的建议是领域层尽量不依赖任何 JSON 注解。原因有两点一领域层引入 Jackson 注解会让它依赖外部框架破坏独立性二直接序列化领域对象往往会把内部结构暴露给外部。更合理的做法是定义独立的 DTO 或事件对象专门用于 API 层传输和消息队列发布。比如OrderCreatedEvent我可以设计成包含订单号、用户 ID 等必要字段的事件模型而不是让消息队列直接发 Order 聚合根。这样以后订单聚合内部结构变化不会直接破坏下游消费者。这里涉及一个“表达模型”和“操作模型”分离的问题。领域模型是操作模型它负责业务规则和一致性而 API 层面对前端的是表达模型DTO它是按接口需要裁剪过的。两者之间的转换在应用层或者用户接口层做。看上去多写了一些转换代码但长期维护收益非常大。5.2 聚合根持久化的选择JPA 与 MyBatis 的两难关于聚合根的持久化我观察到一个很实际的现象用 JPA/Hibernate 的团队更容易落地 DDD因为 JPA 允许你从领域模型出发建立对象关系映射而且有脏检查、级联保存一个聚合根 save 就能级联把整个聚合内部的数据都写进多张表。MyBatis 则更倾向于 SQL 直接对应表结构写聚合内多个对象时经常需要手工控制插入/更新顺序稍不留神就会漏字段。如果你的技术栈是 MyBatis 且不想更换几个实践建议可以参考聚合根的仓储实现里用 Unit OfWork工作单元模式管理内存中聚合的变更最后统一 flush。订单聚合内部的订单项不要单独提供 update 接口而是在保存时先删除旧的子项再插入新集合或者做增量对比。领域对象转持久化对象PO的 Mapping 逻辑集中放在仓储层内部不要让 Service 层感知 PO。但这里想说明一个本质问题DDD 并不规定你必须在 JPA 还是 MyBatis 之间二选一。它的核心要求是“持久化细节不进领域层”。你用什么框架实现仓储是实现自由。但为了省事很多团队在基础设施层直接用 JPA 注解标注领域对象这在领域复杂度和团队规模有限的情况下是可以接受的如果领域模型非常复杂我更推荐独立的持久化模型。没有绝对标准关键是知道自己为了什么取舍。5.3 事务边界应用服务还是聚合根事务边界这个问题团队里经常吵。有人主张事务应放在应用服务方法上Transactional因为它是用例边界也有人认为事务应该由仓储控制。我个人的经验是只能把事务放在应用服务或用例边界绝对不能放在领域对象内部。原因很简单领域对象不应该感知事务。如果 Order 内部自己开事务意味着领域层依赖了事务框架而且事务粒度也被锁死在单个聚合根上一旦一个用例需要同时更新两个聚合你这个事务就根本管不到另一处。应用服务作为一个用例的协调者天然是事务边界的正确位置。但有一点必须提醒如果一个用例里跨了多个聚合根这个事务只应该保证它从“开始”到“持久化”之间的一致性而不应该试图把所有关联聚合的数据强一致地放进同一个数据库事务里。跨聚合的强一致在大规模分布式系统里几乎不可能做到也不应该强求。正确的姿势核心写操作落在各自聚合的仓储上跨聚合间的协调采用最终一致性比如领域事件 重试 对账。5.4 领域事件的发布时机事务发件箱模式如果聚合内有领域事件发布时机很讲究。直接在聚合方法里调用消息中间件发布事件是最危险的做法——如果事件已发布但之后事务回滚了消费者会基于一个“从未发生”的变化去执行逻辑造成严重的数据不一致。我常用的方案是“事务发件箱模式”聚合根产生的事件先记录到本地 outbox 表和业务数据放在同一个数据库事务里提交然后后台任务去轮询 outbox 表把事件发布到消息队列或者直接推送下游。这个方法看似多了一点轮询逻辑但它保证了业务数据和事件的一致性。团队小、量不大时你也可以简化聚合根先把事件放在内存列表中应用层在事务成功提交后统一发。但如果你们对一致性要求比较高最终一致性缺口不能接受outbox 模式是最稳妥的。6. 实践中的注意点怎么避免 DDD 变成新的“过度设计”写了这么多 DDD 的细节最后必须说点泼冷水的话DDD 并不是规模越小、逻辑越简单的项目的解药。我见过太多团队为了“领域驱动”引入一堆概念和模式最后代码量翻倍、解释成本飙升出 bug 概率反而更高。DDD 是解决“业务复杂度”的工具不是解决“技术复杂性”的工具。如果业务本身五张表就能说清楚老老实实写 CRUD 不比任何架构差。从团队实操角度有几个建议值得考虑从核心域开始探索。不是所有业务模块都用 DDD聚焦在“核心域”——也就是公司最核心、规则最复杂、变化最频繁的那部分比如电商里的交易、金融里的风控。周边支撑模块公用基础能力、报表查询等完全可以走传统方式。建模的起点是业务事件和业务规则不是数据库表。很多团队一画 ER 图就停不下来但 DDD 的建模起点应该是事件风暴Event Storming或者用户故事把业务规则和场景先找出来再推导出对象。领域对象重量把控好。没有一层不变的标准订单这种聚合根确实会有较多信息和方法但内部子实体订单项应该保持简单避免每个对象都做成“万能对象”。先跑通端到端的最小闭环再扩展边界。不要一上来就把限界上下文、防腐层全部铺满先在一条核心链路上走通 DDD 的对象设计、仓储和事务团队理解一致后再推广到其他模块。考虑到团队协作我还想多说一点DDD 的有效实施不只是技术活还需要业务专家参与。你设计聚合边界时如果业务专家对“哪些操作必须强一致”有不同说法大概率是需求没聊透而不是技术方案有问题。让业务规则驱动对象设计而不是让数据库表结构驱动对象设计这才是“领域驱动”四个字的原始含义。做到这一层DDD 才能真正帮你控制复杂度而不是变成一套听着高级、用着重重的行业黑话。