ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

领域模型代码示例:业务规则如何住进聚合根

领域模型代码示例:业务规则如何住进聚合根

简介:面向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 List<OrderItem> items; // 聚合内部实体 private Money totalAmount; // 值对象:总额,派生值 private OrderStatus status; // 枚举:状态机 private final Address shippingAddress; // 值对象:收货地址 private String cancelReason; // 取消原因,业务上需要留痕 private LocalDateTime paidAt; private Order(OrderId id, CustomerId customerId, List<OrderItem> 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, List<OrderItem> 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(List<OrderItem> 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 List<OrderItem> 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

依赖方向永远指向 domain:infrastructure 依赖 domain,application 依赖 domain,interfaces 依赖 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 Optional<Order> 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 注解,所有状态变更走行为方法。有些时候确实多写几行,但第二次改业务规则时,那种“只改一处就稳了”的体验,是之前贫血模型里完全体会不到的。这些年踩过的坑、翻过的车,最后都变成了这几条纪律。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表