如何用 Builder 构建复杂聚合对象?和普通对象 Builder 有什么区别?
简化版
复杂聚合对象的 Builder 不只是收集字段,还要管理子对象、集合、默认规则、跨对象一致性和防御性拷贝。它适合订单、报表、查询条件、消息模板这类由多个部分组成的对象,但要避免把业务流程都塞进 Builder,导致 Builder 变成过重的“组装服务”。
详细版
普通对象 Builder 主要解决参数多、可选项多的问题;复杂聚合 Builder 解决的是“多个部分如何一致地组装成一个整体”。比如订单对象可能包含:
- 订单基础信息;
- 买家和收货地址;
- 多个订单明细;
- 优惠、运费、税费;
- 支付方式;
- 扩展属性。
这时 Builder 的职责通常包括:
- 提供
addItem()、address()、coupon()这类贴近业务的构建方法; - 在
build()里检查明细不能为空、金额不能为负、优惠不能超过商品金额; - 对集合和可变对象做防御性拷贝;
- 保证最终聚合对象创建后处于一致状态;
- 不把外部服务调用、库存扣减、支付创建等业务动作塞进 Builder。
面试要强调边界:Builder 负责构建内存对象,领域服务负责执行业务流程。如果 Builder 里开始查数据库、发消息、调用远程服务,它就偏离了创建型模式的定位。
完整版教学
一、复杂聚合对象为什么比普通对象难构建
普通 Builder 可能只是把 8 个字段收集起来,然后调用构造器。复杂聚合对象不同,它内部常常有多个子对象和集合,并且这些部分之间存在一致性约束。比如订单聚合中,订单总金额必须等于明细金额、优惠、运费和税费计算后的结果。
Order
├─ Buyer
├─ Address
├─ List<OrderItem>
├─ Discount
└─ PaymentPlan
如果让调用方自己 new 这些子对象,再手动计算总价,很容易出现半成品对象。Builder 的价值在于提供一个统一施工区:调用方逐步放入部件,build() 负责最后检查和冻结。
记忆钩子:普通 Builder 管“字段太多”,聚合 Builder 管“部件之间必须拼得上”。
二、聚合 Builder 常见 API 形态
复杂聚合 Builder 的方法通常比简单 setter 更贴近业务动作。比如构建订单时,addItem(skuId, quantity, price) 比 items(list) 更安全,因为 Builder 可以在添加明细时做局部校验,也能隐藏 OrderItem 的内部构造细节。
Order order = Order.builder()
.buyer("U1001")
.shipTo("Shanghai", "Pudong")
.addItem("SKU-1", 2, 19900)
.addItem("SKU-2", 1, 9900)
.coupon(5000)
.freight(1200)
.build();
这种 API 的优点是调用方不需要知道订单明细对象怎么创建,也不需要自己维护金额计算。Builder 对外暴露的是“我要给订单加一个商品”,而不是“我要操作某个内部 List”。这能减少调用方绕过规则的机会。
三、用数字例子理解跨部件一致性
假设订单有两件商品:SKU-1 单价 199 元、数量 2;SKU-2 单价 99 元、数量 1;优惠 50 元,运费 12 元。用分为单位计算:
itemsTotal = 19900 * 2 + 9900 * 1 = 49700
coupon = 5000
freight = 1200
payable = 49700 - 5000 + 1200 = 45900
如果这些计算散落在调用方,10 个调用点可能出现 10 套金额规则。Builder 可以把这条规则收进 build(),让最终对象里的 payableAmount 总是和明细一致。
public Order build() {
if (items.isEmpty()) {
throw new IllegalStateException("order items required");
}
long itemsTotal = items.stream()
.mapToLong(item -> item.unitPrice() * item.quantity())
.sum();
if (couponAmount > itemsTotal) {
throw new IllegalStateException("coupon exceeds item total");
}
long payable = itemsTotal - couponAmount + freightAmount;
return new Order(buyerId, address, List.copyOf(items), itemsTotal, couponAmount, freightAmount, payable);
}
这里 Builder 不只是“把字段传给构造器”,它在交付对象前完成了聚合一致性检查。
四、集合和子对象为什么必须防御性拷贝
复杂聚合对象最容易踩的坑是集合泄漏。调用方传入一个 List<OrderItem>,Builder 直接保存引用,最终 Product 也直接持有这个引用。这样对象构建完成后,外部代码仍然可以修改明细。
List<OrderItem> items = new ArrayList<>();
items.add(new OrderItem("SKU-1", 2, 19900));
Order order = Order.builder().items(items).build();
items.clear(); // 如果没有拷贝,order 内部明细可能也被清空
稳妥做法是在 Builder 接收集合时复制一次,在 Product 构造时再冻结一次。对于数组、Map、可变子对象,也要按同样思路处理。复杂聚合 Builder 的交付目标不是“拼出对象”,而是“拼出不会被外部引用继续污染的对象”。
| 输入类型 | 风险 | 推荐处理 |
|---|---|---|
| List/Set | 外部增删元素 | new ArrayList<>(input) + List.copyOf |
| Map | 外部改键值 | 复制并冻结 |
| byte[] | 外部改数组内容 | Arrays.copyOf |
| 可变子对象 | 内部状态被改 | 使用不可变值对象或复制 |
五、Builder 和领域服务的职责边界
复杂聚合 Builder 很容易越界。比如订单 Builder 如果开始查库存、锁优惠券、创建支付单、发送消息,它就不再只是对象构建器,而变成了业务编排器。这样会带来测试困难和副作用不可控的问题。
更清楚的分工是:
Builder:
收集构建参数
创建子对象
校验内存内一致性
防御性拷贝
Domain Service / Application Service:
查库存
校验用户状态
锁定优惠券
调用支付
发布领域事件
Builder 可以验证“明细不能为空、金额不能为负、优惠不能超过商品金额”,但不应该验证“这个 SKU 当前库存是否足够”,因为库存需要外部数据源。把外部依赖放进 Builder,会让一个原本简单的创建模式变成难测的服务对象。
六、什么时候需要子 Builder
如果聚合内部子对象也很复杂,可以为子对象提供独立 Builder,再由聚合 Builder 接收成品子对象或提供嵌套构建入口。例如报表对象包含筛选条件、排序规则、展示列、导出格式,每个部分都有自己的规则。
ReportBuilder
.filter(Filter.builder().eq("status", "PAID").between("amount", 100, 500).build())
.sort(Sort.by("createdAt").desc())
.column("orderId")
.column("amount")
.build()
是否拆子 Builder,看两个指标:子对象能否独立复用,子对象规则是否足够复杂。如果只是两个字符串,拆出来会显得啰嗦;如果子对象自己有 5 个字段、3 条组合规则,就值得独立建模。
七、聚合 Builder 的测试重点
复杂聚合 Builder 的测试不能只测“能 build 成功”。要覆盖正常路径、缺失部件、金额边界、集合不可变、外部引用污染和非法组合。
测试清单:
1. 0 个 item -> build 失败
2. coupon = itemsTotal + 1 -> build 失败
3. freight = 0 -> 可通过
4. freight = -1 -> build 失败
5. 构建后修改原始 items -> order.items 不变
6. order.items().add(...) -> 抛异常或无法修改
7. 两个 item 金额合计 49700,coupon 5000,freight 1200 -> payable 45900
这些测试能证明 Builder 真正承担了聚合一致性边界,而不只是让调用代码看起来更顺。
八、常见误区与追问
- 误区:复杂聚合 Builder 只是普通字段 Builder 的放大版。 它还要处理子对象、集合、跨部件规则和冻结边界。
- 误区:Builder 里可以顺手查数据库。 外部依赖和副作用属于服务层,放进 Builder 会让构建过程难以预测。
- 误区:Product 构造后集合不会被改。 如果没有防御性拷贝,外部引用仍可能修改内部状态。
- 追问:金额计算放 Builder 还是 Order 内部? 构建时派生字段可由 Builder 计算,核心不变量也可以在 Product 构造器兜底。
- 追问:什么时候拆子 Builder? 子对象规则复杂且可复用时拆;简单值对象不必过度拆分。
- 追问:聚合 Builder 是否等同于领域服务? 不等同,Builder 负责内存对象组装,领域服务负责跨资源业务流程。
- 追问:如何防止 Builder 过重? 保持无外部副作用,只做构建参数、局部规则、聚合一致性和不可变交付。
九、加强记忆
复杂聚合 Builder 要记住“组装、校验、冻结、别越界”。它组装多个部件,校验跨部件一致性,冻结集合和可变引用,但不承担查库、扣库存、发消息这类业务编排。能说清这条边界,才算真正理解 Builder 在工程里的位置。