资讯详情 领域模型代码示例:业务规则如何住进聚合根
📅 2026/10/25 16:13:11
简介面向DDD实践者的完整领域模型示例工程定位于帮助开发者通过可运行代码理解实体、值对象、聚合、领域服务与领域事件等核心概念适合正在学习领域驱动设计或需要落地微服务业务建模的初中级后端工程师。压缩包内含126个文件以Java源码、class编译产物、jar依赖包为主辅以XML配置、属性文件与工程描述文件能够直接还原完整项目结构。目前已有1266人学习下载。示例覆盖账户、转账等典型业务场景清晰展示了聚合边界划分、仓储接口实现、门面与事务封装方式并演示了如何利用领域事件实现模块解耦与扩展。通过阅读代码可快速掌握DDD战术建模在真实工程中的组织方式为后续独立设计领域模型提供可模仿的模板。1. 领域模型代码示例业务规则到底该住在哪一层如果你也经历过这样的日子Controller 薄如纸Service 厚如砖改一个订单取消规则要串改三个文件——那你应该看看这份领域模型代码示例。领域模型Domain Model的核心思想很简单把业务规则放回模型对象自己身上让订单自己决定能不能取消、让金额自己计算、让状态流转自己闭环而不是在 Service 里堆事务脚本。这篇不是 DDD 大部头导读而是一份可以直接上手的落地笔记从订单聚合根骨架、仓储接口、单元测试到贫血模型怎么安全重构。适合手头有订单、工单、审批流这类业务、想试又怕过度设计的同学。2. 领域模型长什么样一个订单聚合根的骨架代码与选型判断2.1 实体、值对象、聚合根的分工与选择做领域模型之前先分清楚三类概念。实体Entity有唯一标识、有生命周期、状态可变值对象Value Object没有标识、不可变、靠“值相同”来比较聚合根Aggregate Root是一个一致性边界外部对聚合内部对象的操作都必须经过它。判断一个概念应该建模成什么我习惯问三个问题它有没有身份它变还是替换它能不能被聚合外的操作直接改状态比如订单明细 itemId 是主键但业务上我们从来不会说“把订单项改成另一条”只会说“往订单里加一个商品”或“取消订单”。明细没有独立于订单的生命周期所以它不该是聚合根而是聚合内部的实体。金额和地址则更典型199 元和另一个 199 元没有任何身份区别替换掉也不影响语义所以建模成值对象不可变、构造时校验。建模元素特征典型例子实体有唯一标识生命周期可变Order、OrderItem值对象无标识不可变按值比较Money、Address聚合根一致性边界外部访问入口Order边界判断口诀看“是否必须同时守住某条业务不变量”。总金额必须等于明细之和所以 OrderItem 放在 Order 这个聚合里订单和客户只是有关联不是强一致所以客户不能划进订单聚合。把这条记住聚合边界就不会划成一个大泥球。2.2 订单聚合根的示例代码状态机与金额计算内聚在哪下面这份是我的订单聚合根骨架你可以直接抄进工程里改。要注意它的状态流转全部通过行为方法完成没有公开 setter——这是和“数据库表搬进 Java 类”最根本的区别。public class Order { private final OrderId id; // 值对象订单唯一标识 private final CustomerId customerId; // 值对象客户标识 private final ListOrderItem items; // 聚合内部实体 private Money totalAmount; // 值对象总额派生值 private OrderStatus status; // 枚举状态机 private final Address shippingAddress; // 值对象收货地址 private String cancelReason; // 取消原因业务上需要留痕 private LocalDateTime paidAt; private Order(OrderId id, CustomerId customerId, ListOrderItem items, Address shippingAddress) { if (id null || customerId null || shippingAddress null) { throw new IllegalArgumentException(订单id、客户id与收货地址不能为空); } if (items null || items.isEmpty()) { throw new IllegalArgumentException(订单至少要包含一个商品明细); } this.id id; this.customerId customerId; this.items new ArrayList(items); this.shippingAddress shippingAddress; this.totalAmount sumAmount(items); // 派生字段在构造时一次算好 this.status OrderStatus.CREATED; } // 唯一创建入口把不变量校验收在构造器里 public static Order create(OrderId id, CustomerId customerId, ListOrderItem items, Address address) { return new Order(id, customerId, items, address); } // 行为方法只有创建中的订单允许追加商品 public void addItem(Sku sku, int quantity, Money price) { if (this.status ! OrderStatus.CREATED) { throw new OrderStateException(只有创建中的订单允许追加商品); } if (quantity 0) { throw new IllegalArgumentException(商品数量必须大于0); } this.items.add(OrderItem.of(sku, quantity, price)); this.totalAmount sumAmount(this.items); } // 行为方法创建中的订单才能支付支付后状态锁死 public void pay() { if (this.status ! OrderStatus.CREATED) { throw new OrderStateException(当前订单状态不允许支付); } this.status OrderStatus.PAID; this.paidAt LocalDateTime.now(); } // 行为方法发货由领域服务编排但状态机器校验在模型里 public void ship() { if (this.status ! OrderStatus.PAID) { throw new OrderStateException(只有已支付订单才能发货); } this.status OrderStatus.SHIPPED; } // 行为方法取消需要原因已发货订单不可取消 public void cancel(String reason) { if (this.status ! OrderStatus.CREATED this.status ! OrderStatus.PAID) { throw new OrderStateException(已发货订单不能取消); } if (reason null || reason.isBlank()) { throw new IllegalArgumentException(取消原因不能为空); } this.status OrderStatus.CANCELLED; this.cancelReason reason; } // 私有方法金额求和只在模型内部出现不对外暴露 private Money sumAmount(ListOrderItem items) { Money sum Money.zero(); for (OrderItem item : items) { sum sum.add(item.getSubTotal()); } return sum; } public OrderId getId() { return id; } public OrderStatus getStatus() { return status; } public Money getTotalAmount() { return totalAmount; } public ListOrderItem getItems() { return Collections.unmodifiableList(items); } // 注意不提供 setStatus / setTotalAmount状态变更只能走行为方法 }逻辑说明构造器不是数据库读取工具它就是业务规则的第一个入口——空订单不允许创建、明细为空不允许落库。addItem 先校验状态再变更集合最后重算金额直接把金额暴露在外面随便改等于把“总金额明细之和”这条不变量放弃了。getItems 返回的是不可变视图防止调用方绕过 addItem 直接往订单里塞数据。参数说明Sku 是商品标识值对象把它从 String 提升成类可以避免“SKU-001”和“sku001”这种脏字符串在系统里到处传播quantity 在示例里先用了 int如果你要防止超卖建议后续包成 Quantity 值对象Money 内部是 BigDecimal 金额加币种禁止用 double。状态枚举 OrderStatus 建议定义为 CREATED、PAID、SHIPPED、CANCELLED 四个值合法迁移表就对应上面几个方法的 if 判断。提示上面代码里的 OrderId.of、Money.of 是工厂方法作用是统一做非空和格式校验。领域模型里我习惯所有简单类型都从工厂方法创建而不是直接 new。2.3 领域服务与仓储接口哪些逻辑必须放在模型外面模型不是把所有代码都装进去。涉及外部系统调用、跨聚合编排、事务控制的部分应该放在领域服务或应用服务里。规则只有一个模型方法里不要出现 repository、mapper、kafka、http 调用模型不知道自己的存续方式。Service public class OrderFulfillmentService { private final OrderRepository orderRepository; private final InventoryGateway inventoryGateway; public OrderFulfillmentService(OrderRepository orderRepository, InventoryGateway inventoryGateway) { this.orderRepository orderRepository; this.inventoryGateway inventoryGateway; } Transactional public void ship(OrderId orderId) { Order order orderRepository.findById(orderId) .orElseThrow(() - new OrderNotFoundException(orderId)); // 外部库存锁定放服务里编排不放模型 inventoryGateway.lock(order.getItems()); // 状态机校验与变更放模型里 order.ship(); orderRepository.save(order); } }仓储接口定义在领域层实现在基础设施层。领域层不依赖 MyBatis 或 JPA表结构怎么设计、要不要加缓存都由实现去管。有了这层隔离模型可以单独编译、单独测试业务规则不会被表字段拖着走。判断逻辑归属时我一般先问这条规则需不需要查别的聚合需不需要调外部系统都不需要就放进模型有依赖就放在服务里编排并保持模型方法纯粹。3. 把第一个领域模型代码示例跑通订单履约场景讲解3.1 工程目录怎么切按业务能力分包而不是按三层架构分包很多项目包结构是 controller/service/mapper/entity你顺着这个结构写领域模型写着写着就回到贫血模型。我的习惯是直接按业务模块切包一个模块内部再分 domain/application/infrastructure/interfaces。以下目录可以直接作为新项目的起点com.example.order ├── domain/ 领域层模型、仓储接口、领域服务 │ ├── model/ Order、OrderItem、Money、OrderStatus │ ├── repository/ OrderRepository 接口 │ └── service/ OrderFulfillmentService ├── application/ 应用层用例编排、事务边界 ├── infrastructure/ 基础设施层Mapper 实现、外部网关 └── interfaces/ 接口层Controller、DTO依赖方向永远指向 domaininfrastructure 依赖 domainapplication 依赖 domaininterfaces 依赖 application。domain 那一层不 import 任何 Spring、MyBatis 注解。这样做的直接好处是领域模型可以被纯 Java 单测驱动不需要等容器启动。很多团队不是不想用领域模型是被包结构带偏了包结构一变写代码的手感自然跟着变。3.2 仓储实现与数据库表模型与表结构不一致时的转换层模型里 totalAmount 是派生值数据库却通常冗余一列 total_amount。表结构少一列查询要 join 出来算表结构多一列写入时要保证两边一致。这个矛盾的解法不是让模型迁就表而是让仓储实现做转换。Repository public class OrderRepositoryImpl implements OrderRepository { private final JdbcTemplate jdbcTemplate; Override public void save(Order order) { // 聚合内部对象少直接全量覆盖先删子表再写主表和子表 jdbcTemplate.update(DELETE FROM order_item WHERE order_id ?, order.getId().value()); jdbcTemplate.update( INSERT INTO orders (order_id, customer_id, status, total_amount, cancel_reason) VALUES (?, ?, ?, ?, ?), order.getId().value(), order.getCustomerId().value(), order.getStatus().name(), order.getTotalAmount().getAmount().toPlainString(), order.getCancelReason() ); for (OrderItem item : order.getItems()) { jdbcTemplate.update( INSERT INTO order_item (order_id, sku, quantity, unit_price, currency) VALUES (?, ?, ?, ?, ?), order.getId().value(), item.getSku().value(), item.getQuantity(), item.getUnitPrice().getAmount().toPlainString(), item.getUnitPrice().getCurrency().getCurrencyCode() ); } } Override public OptionalOrder findById(OrderId id) { // 先查主表再查子表最后用工厂方法重建聚合根 // 读取时不在仓储里做业务判断校验交给模型构造器 return Optional.ofNullable(...); } }逻辑说明save 采用全量覆盖而不是增量 diff因为聚合内部对象数量少删了重写最简单。金额用 toPlainString() 转字符串写库避免 BigDecimal 的科学计数法把表里数值变样币种单独落一列防止多币种环境里金额和货币错位。读取时走模型工厂方法重建 Order一旦数据库里有脏状态会在构造器里抛异常而不是带病运行。参数说明order.getCancelReason() 有可能为 null对应 cancel_reason 列允许为空status 存枚举名字符串插值可读性好但要注意数据库别用 MySQL 的 enum 类型不然以后加状态要改表结构。findById 返回 Optional调用方必须处理“订单不存在”而不是等着空指针——这是接口设计对使用方的约束。3.3 用单元测试锁定业务规则组件测试才碰数据库领域模型最大的红利之一是大多数业务测试不用启动 Spring 容器、不用连数据库。规则都在模型方法里直接 new 出来就能验证。下面这段测试就是典型的模型测试class OrderTest { Test void 创建中的订单支付成功后不能再次支付() { Order order Order.create( OrderId.of(20240101001), CustomerId.of(C001), List.of(OrderItem.of(Sku.of(SKU-001), 2, Money.of(new BigDecimal(199.00)))), Address.of(北京市朝阳区, 100020) ); order.pay(); assertThrows(OrderStateException.class, order::pay); } Test void 已发货订单不能取消() { Order order createPaidOrder(); order.ship(); assertThrows(OrderStateException.class, () - order.cancel(用户说不要了)); } Test void 订单总额等于明细之和() { Order order Order.create(...); assertEquals(Money.of(new BigDecimal(398.00)), order.getTotalAmount()); } }这些测试断言的是业务规则本身不关心数据从哪来。跑起来毫秒级CI 里随时执行不用等测试库数据。等模型成型后真正需要启动 Spring 的组件测试只保留仓储读写和外部网关的集成测试数量会明显少于以前。提示中文方法名在 Java 里完全合法。团队能接受就用不能接受就换成 payTwiceShouldFail 这类英文名重点是测试描述的是行为而不是实现步骤。4. 做领域模型时的四个常见坑现象、原因与排查我见过不少团队在领域模型上栽跟头多数不是方向错而是几个看似小的地方没守住。下面四条基本每条都亲历过。4.1 模型被 ORM 绑架注解一加行为就瘦了现象新写的 Order 类上很快出现 TableName 和一堆 TableField字段加上 Lombok 的 Data。刚开始还会写行为方法但 setter 太顺手改状态绕过了业务校验几个月后模型退化回纯数据类规则又爬回 Service。原因把领域模型等同于持久化实体。领域模型只关心业务不变量持久化实体只关心怎么把状态落库。两者混在一起ORM 的便利性会持续引诱你绕过规则直接改字段。排查时直接看代码库 diff新 Model 文件里是否出现了 javax.persistence 或 com.baomidou 前缀的导入如果出现了说明分层已经被打穿。解决领域模型类上不允许出现任何 ORM 注解持久化对象单独放 infrastructure 包用转换方法映射。我给自己定的纪律模型类不引入 MyBatis、不引入 Lombok 的 Data只有行为方法和必要的查询 getter。必要时手写几行 getter能让你意识到“暴露了一个字段”这个动作本身是需要斟酌的。4.2 setter 比行为方法还多这是典型的贫血模型现象核心类里有十几个 setter行为方法只有一两个。新来的同事接手时根本不知道订单创建后应该调用什么上下文全靠读 Service。翻代码时看到一个方法前一半是 setStatus、setAmount后一半才是业务逻辑这就是失血信号。原因建模时按数据库字段建模而不是按业务能力建模。字段是静态的行为是动态的你盯着表写类得到的自然是一堆 getter/setter。解决给模型定纪律——公开方法只保留查询 getter 和动词行为方法所有 setter 私有或删除。想写 public setStatus 时先问自己这个状态迁移要满足什么业务规则规则放在哪里回答不上来就不写。测试里再加一道防线比如禁止通过反射调用私有 setter 的规矩虽然有点极端但对大团队有效。4.3 聚合边界划错事务和锁一起失控现象聚合根越划越大一个聚合里包含订单、客户、优惠券、库存一次下单要锁几十行并发一高就死锁。为了“数据一致”把关联对象全部塞进聚合结果所有操作都要经过同一把锁。排查时看数据库的锁等待日志会发现所有慢事务都在抢同一张聚合表的行锁。原因用“对象之间有关联”而不是“必须强一致”来划边界。订单和客户有关联但创建订单时并不需要锁住客户这一行优惠券和订单也有关联但优惠券的扣减可以接受最终一致。解决聚合边界只覆盖那些“同时违反会立刻出大事”的不变量。总金额必须等于明细之和这个必须强一致客户余额和订单状态可以最终一致。跨聚合的协同通过应用服务编排或领域事件异步处理。判断边界时多问一句两条数据如果暂时不一致业务会不会炸不会就别放进同一个聚合。4.4 建模过度领域事件满天飞没人消费现象刚学完 DDD 很兴奋OrderCreatedEvent、StockLockedEvent、CouponIssuedEvent 发了一堆每个事件都写了监听器。半年后一查一半监听器没有任何调用方消息中间件的 topic 越建越多运维成本跟着涨。这种翻车最隐蔽因为代码看起来很“规范”但没有人为结果负责。原因把领域事件当成必选项甚至是 KPI。新同学看到事件风暴很酷项目里就到处是事件维护成本翻倍。解决事件只在“确实需要跨聚合或跨系统异步协同”时引入。当前只需要同步调用一个仓储拿另一个对象改状态就直接同步做不要为了模式而模式。我用一条原则先证明单机事务处理不了再上事件和消息。所有异步都会引入数据最终一致性的处理成本不是免费的。5. 从贫血模型安全重构到领域模型一条能回滚的路线5.1 识别失血的 Service 方法用业务动词重新圈地第一步是把现有 Service 方法按业务动词列清单createOrder、cancelOrder、payOrder、shipOrder、refundOrder。这些动词几乎就是模型行为方法的候选。然后把每个方法拆成两类代码规则判断和流程编排。规则判断是“如果状态是 X不允许 Y”“金额必须大于 0”“取消必须有原因”流程编排是调库存、发消息、开事务。如果规则判断占了一个方法三四成以上代码这个业务概念就值得被建模。如果是纯 CRUD 方法一行规则都没有那就别建模老老实实写个转换层就行。这个方法清单不用面面俱到挑三个最痛的先圈出来。5.2 迁移顺序先竖切一个场景再横切整条链路最常见的错误是一上来要把整个 service 层重写成领域模型。战线拉长代码无处回滚。我一般选最痛的一个场景先做。订单取消就是很好的切入点它有状态判断、有原因校验、有跨聚合的优惠券退还但链路又足够短。重构前Transactional public void cancel(Long orderId, String reason) { OrderPO po orderMapper.selectById(orderId); if (po null) throw new OrderNotFoundException(orderId); if (po.getStatus() ! 1 po.getStatus() ! 2) { throw new IllegalStateException(当前状态不能取消); } po.setStatus(5); po.setCancelReason(reason); orderMapper.updateById(po); couponService.refund(po.getCouponId()); }重构后Transactional public void cancel(Long orderId, String reason) { Order order orderRepository.findById(OrderId.of(orderId)) .orElseThrow(() - new OrderNotFoundException(orderId)); order.cancel(reason); orderRepository.save(order); couponService.refund(order.getCouponId()); }对比很清楚重构后 Service 只做“找对象、让对象自己完成业务、持久化、做跨聚合协调”。状态判断和原因校验跑进了模型里。重构时不要顺手改表结构也不要顺手优化 SQL一次只改一件事回滚时才不会混淆。新代码没通过测试就切开关回到老 Service也是这节的后悔药。5.3 重构期间怎么验证行为没变特征测试与用例基线迁移前先给要被迁移的老方法写特征测试。所谓特征测试就是不管代码写得对不对把当前行为固化下来输入什么参数调用、返回什么、数据库变成什么样。跑一遍记录结果以后回归时对照。Test void cancel_老逻辑_CREATED状态转CANCELLED并留原因() { // 准备插入状态为 CREATED 的订单 // 调用cancelService.cancel(orderId, 用户说不要了) // 断言订单状态变为 CANCELLED、cancel_reason 有值、优惠券被退回 }特征测试写在应用层可以启动 Spring但千万别让它们依赖测试库里的脏数据。迁移完成后跑同一份测试输出一致即通过。通过之后再决定要不要保留这些特征测试——我一般会保留状态流转类删掉纯粹的数据读写类。6. 一个硬指标业务规则变更时你的测试改动了几行模型建得好不好别听概念讲得多漂亮看一次真实业务规则变更你改了几个文件。假设促销规则变了“订单满 300 减 50每个订单最多一个优惠”。贫血模型下这个逻辑通常埋在 Service 的一个方法里六层 if 嵌套你要先读二十行上下文才敢动改了还得祈祷没破坏别的分支。换到领域模型下这个规则只落在一个策略对象或订单聚合根的金额计算方法里你只需要改这个对象然后重算金额的测试。订单的创建、支付、状态流转测试一行都不用动——它们根本不关心价格是怎么算出来的。这个指标可以量化跟踪。每次规则变化时记三笔账改动的文件数、改动的代码行数、受影响的测试数。领域模型成熟后你会看到核心业务规则的变化收敛到一两个类。如果一次变更要跨五个类、改三层代码先别急着改停下来看看是不是聚合边界划错了。业务规则变更场景贫血模型典型改动量领域模型典型改动量满减规则调整改 Service 嵌套判断 重写整段测试改策略类或金额方法 改金额测试订单新增一个可取消状态改 Service 判断 改 DTO 改 DB 枚举改状态枚举 改行为方法校验进阶一点可以用变异测试给业务测试上强度。跑 PIT 这类工具故意往模型方法里注入错误把状态判断反过来、把金额比较去掉看测试能不能立刻报错。有价值的信息不是覆盖率数字而是核心行为——订单状态机、金额计算、取消校验——在变异后是否暴雷。暴雷越快测试对规则的锁定越牢。我自己的习惯是新建模型类第一天就不写公开 setter不引入 ORM 注解所有状态变更走行为方法。有些时候确实多写几行但第二次改业务规则时那种“只改一处就稳了”的体验是之前贫血模型里完全体会不到的。这些年踩过的坑、翻过的车最后都变成了这几条纪律。希望帮到你。本文还有配套的精品资源点击获取