实体、值对象与聚合

共 19 题
📑 题目列表 19 题
#
★★★

1. Eric Evans DDD 蓝皮书的实体(Entity)与值对象(Value Object)的核心差异中身份 vs 属性

请解释 Eric Evans 在 DDD 蓝皮书中定义的实体(Entity)与值对象(Value Object)的核心差异,并说明"身份 vs 属性"这一判定的具体含义?

  • 实体与值对象在"身份标识"上的本质区别
  • 判定一个对象是实体还是值对象的依据
  • 实体与值对象在生命周期、可变性、相等性上的差异

核心差异在于"身份"(identity)与"属性"(attribute)的定位。实体具有贯穿其生命周期的连续身份标识,即便它的属性全部改变,只要身份不变,它就是同一个对象。例如一个订单(Order)即使改了状态、金额、收货地址,它仍然是同一个订单,靠订单号(orderId)识别。值对象则没有独立的身份,它由一组属性值定义,一旦属性值改变,它就变成了另一个值对象。例如地址(Address)由省、市、街道构成,改变任何属性就得到另一个地址,没有"同一性"可言。在判定上,问题问的是"这个对象有身份吗?它会在生命周期中变化吗?它是被复制还是被区分?"——需要跟踪身份、有生命周期的用实体;仅描述属性、内部属性整体才有意义、可被替换的用值对象。值对象通常不可变,两个值对象相等与否取决于全部属性值是否相等,而实体相等仅取决于身份标识。

选择实体还是值对象直接影响设计质量。把没有身份的对象当实体处理会引入不必要的 ID 和可变状态,增加复杂度;把有身份的对象当值对象处理则会丢失身份标识,导致无法追踪。蓝皮书强调"身份判断"是建模的第一步,多数领域模型中的"小对象"(金额、时间范围、地址、颜色)都应是值对象。

// 实体:身份相同即同一对象
public class Order {
    private final OrderId id;   // 身份标识
    private OrderStatus status;
    // 属性改变,但身份不变
}

// 值对象:无身份,全部属性构成其值
public final class Address {
    private final String street;
    private final String city;
    // 没有 id,属性相等即对象相等
}
#
★★★

2. Money、Address、DateRange 等典型 Value Object 的实现

请以 Money、Address、DateRange 等典型对象为例,说明值对象(Value Object)在 Java 中的实现方式与要点?

  • 值对象的不可变性与防御性设计
  • 典型值对象(Money、Address、DateRange)的实现模式
  • 值对象提供业务操作而非暴露裸属性

典型的值对象实现遵循几个共性:类声明为 final(或不可继承),所有字段为 final 且通过构造器注入,不提供 setter,提供基于全部字段的 equals/hashCode 和 toString,并围绕业务语义提供操作(而非暴露属性)。Money 应同时持有金额与币种,并提供 add、subtract 等操作,绝不能把金额当成裸 double 处理;Address 由街道、城市、邮编等组成,提供校验与格式化;DateRange 由开始/结束时间组成,提供 contains、overlaps、duration 等业务方法。所有字段在构造时即校验,构造后无法再修改。

值对象把"一组相关属性"封装成一个有行为、有约束的领域概念,而不是七零八落的裸数据。通过方法暴露业务能力(如 price.add(other)),客户端不必关心内部实现,也避免在多个地方重复实现金额运算、日期比较等逻辑。

public final class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        if (amount == null || currency == null) throw new IllegalArgumentException();
        this.amount = amount;
        this.currency = currency;
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) throw new IllegalArgumentException("币种不一致");
        return new Money(this.amount.add(other.amount), this.currency);
    }
    // equals/hashCode 基于 amount 与 currency
}
#
★★★

3. Value Object 的不可变性(immutability)与"创建-修改"模式(with 方法)中为什么推荐 immutable?

为什么推荐值对象使用 immutable(不可变)设计?"创建-修改"模式(with 方法)如何在不破坏不可变性的前提下实现"修改"?

  • 不可变值对象的线程安全、共享安全、行为一致性
  • with 方法返回新实例而非修改自身的模式
  • 不可变对象与哈希、缓存、equals 的配合

推荐 immutable 的原因包括:线程安全(多个线程共享同一实例无竞态)、可安全共享(可被多个聚合/上下文复用而无需拷贝)、hashCode 稳定(可安全用于 HashSet/HashMap 缓存键)、无副作用、便于推理与测试。由于值对象没有身份,其"修改"语义天然是"替换为新的值",因此采用 with 方法(如 withAmount、withCurrency)返回一个新实例,而不是修改原有实例。例如 order.addItem() 后订单金额变为新 Money 实例,原 Money 保持不变。with 方法内部用构造器或 builder 创建新对象,保证构造后不可变。

不可变的核心好处是"值对象即值",值不可变才能被放心共享。若用可变值对象,一个聚合修改后会意外影响另一个共享同一实例的聚合,破坏不变量。with 方法让"修改"清晰体现为"新值替换旧值",同时保持不可变特性。

public final class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money withAmount(BigDecimal newAmount) {
        return new Money(newAmount, this.currency); // 返回新对象
    }
    // 原对象不变,支持安全共享
}
#
★★★

4. 值对象的相等性(equals/hashCode)设计中基于全部属性 vs 业务标识的取舍,以及金额(浮点陷阱)、带时区时间等易错场景如何正确实现?

值对象的 equals/hashCode 应如何设计?基于全部属性与基于业务标识的取舍是什么?金额用浮点、带时区时间等易错场景如何正确实现?

  • 值对象 equals/hashCode 基于全部属性的实现
  • 浮点金额、时区时间等精度陷阱
  • equals 与 hashCode 的一致性契约

值对象的 equals 应基于全部属性字段,hashCode 与 equals 使用一致的字段集合,保证"相等则 hashCode 相同"。金额绝不能使用 double/float 存储与比较,应使用 BigDecimal(并指定 scale),或采用整数分(cents)表示,否则浮点误差会导致两个相等金额比较失败。带时区的时间应统一存储为 UTC 的 Instant 或带时区的 ZonedDateTime,比较时先归一化到统一时区再比较,避免不同时区但同一时刻的实例被误判为不相等。equals 实现应使用 Objects.equals 或基本类型直接比较,避免 == 比较引用。

值对象的相等性直接决定业务语义正确性(如 Map 中按金额查找、Set 去重)。浮点与时区是两类高频陷阱:浮点无法精确表示十进制小数,而时区比较必须统一基准。把归一到 BigDecimal 与 UTC 的职责放在值对象内部,比在调用方各自处理更可靠。

public final class Money {
    private final BigDecimal amount; // 用 BigDecimal,不用 double
    private final Currency currency;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money)) return false;
        Money m = (Money) o;
        return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
    }
    @Override
    public int hashCode() {
        return Objects.hash(amount.stripTrailingZeros(), currency);
    }
}
#
★★★

5. 同一概念在不同限界上下文中的角色切换中 Address 作为值对象与实体的判定依据及设计影响?

同一概念在不同限界上下文(Bounded Context)中可能扮演不同的角色,例如 Address 既是值对象又是实体,判定依据是什么?对设计有何影响?

  • 同一概念在不同上下文可拥有不同建模
  • 判定依据:是否存在独立的身份与生命周期
  • 角色切换对持久化、引用、共享的影响

判定依据是"在该上下文中,这个概念是否需要独立的身份与生命周期"。在订单履约上下文里,Address 只是收货地址的一组属性,被复制进订单中,无需跟踪,因此建模为值对象。在客户/CRM 上下文中,Address 可能被单独维护、校验、与客户关联并追踪其变更历史,具有独立身份,因此建模为实体。同一概念在不同上下文可以有不同的建模,这正是限界上下文"各自语言、各自模型"的体现。设计影响包括:值对象 Address 可被安全复制、不可变、随包共享;实体 Address 有主键、可被引用、可更新、有生命周期,需要单独的仓储与标识。

不要试图为全系统统一一个 Address 模型,"全局唯一模型"是单块模型(Model Unification)错误的根源。上下文之间的模型通过上下文映射(如防腐层)翻译,不强制共享一致。识别"是否需要身份"是建模决策的核心。

#
★★

6. DDD 红皮书(Vaughn Vernon)的聚合(Aggregate)设计原则中通过聚合根(Aggregate Root)维护事务边界

请解释 Vaughn Vernon 红皮书中聚合(Aggregate)的设计原则,以及聚合根(Aggregate Root)如何维护事务边界?

  • 聚合的定义与聚合根的作用
  • 聚合根作为外部访问唯一入口
  • 聚合作为事务一致性的边界

聚合是领域对象(实体与值对象)的簇,具有明确边界,外界只能通过聚合根访问聚合内部对象。聚合根是聚合中唯一的实体,是外部与聚合内部交互的唯一入口。聚合内的对象通过聚合根维护的不变量保持一致,聚合根保证聚合内所有状态变化都在一个事务内完成。Vernon 的核心原则是:一个事务只能修改一个聚合,聚合根负责暴露命令方法并保证不变量,聚合内对象不得被外部直接引用或修改,只能通过聚合根间接操作。

聚合把"必须一起变更、必须保持一致"的对象绑定在同一个事务边界内,避免跨聚合的紧耦合事务。聚合根作为门面,既保护聚合内部不变量,又为外部提供清晰的协作接口。过大聚合会带来事务与并发问题,过小聚合则难以保证一致性,需要平衡。

#
★★

7. 聚合根 vs 实体 vs 值对象的协作模式中工厂(Factory)创建复杂聚合

聚合根、实体与值对象如何协作?为何用工厂(Factory)创建复杂聚合?

  • 三类对象的职责与协作关系
  • 复杂聚合的创建逻辑
  • 工厂封装创建细节与不变量

聚合根是聚合的唯一入口,聚合内的实体承载聚合内的业务状态,值对象描述聚合的属性。三者协作时,外部只调用聚合根的命令方法,聚合根内部协调其下属实体与值对象完成状态变更。当聚合创建逻辑复杂(需要组装多个实体、初始化多个值对象、校验复杂不变量)时,使用工厂(Factory 或领域服务)集中创建:工厂负责把创建所需的参数组装成完整聚合,并保证创建时就满足全部不变量,避免把复杂的构造逻辑散落在客户端或构造器中。

简单聚合可以直接用构造器创建,但复杂聚合的构造逻辑(依赖关系、默认值、不变量校验)会污染构造函数。工厂把"创建时机"的逻辑与"创建后的行为"分离,让聚合保持内聚,同时让客户端创建代码变薄。工厂既可以是独立的对象,也可以是聚合根上的静态工厂方法。

#
★★

8. 聚合的"小聚合"原则(Vernon)中优先使用 ID 引用而非对象引用;大聚合 vs 小聚合在一致性与性能上的取舍?

请解释 Vernon 的"小聚合"原则,为何优先使用 ID 引用而非对象引用?大聚合与小聚合在一致性与性能上如何取舍?

  • 小聚合的设计原则
  • ID 引用 vs 对象引用的区别
  • 一致性与性能的权衡

Vernon 提倡"小聚合",即聚合尽量小、只包含必须保持一致的强关系对象。聚合内部用对象引用(方便直接访问),但聚合之间通过 ID 引用而非对象引用,避免跨聚合的强耦合与加载成本。聚合变小带来:并发冲突减少(高并发下修改同一聚合的概率下降)、加载成本降低、事务范围缩小、易于扩展。代价是:跨聚合的一致性需要最终一致(通过领域事件协调),无法保证强一致。大聚合则能在单事务内保证强一致,但会导致并发性能下降、加载开销大、事务边界模糊。

取舍的关键是"一致性是强是弱"以及"这些对象是否总是必须一起变更"。若对象可独立变化、只需最终一致,就拆成小聚合用 ID 引用;若必须同生共死、强一致,就保留在一个聚合内。工程上宁小勿大,用 ID 引用降低耦合。

#
★★

9. 聚合的不变量(invariant)一致性边界中一个事务只能修改一个聚合

聚合的不变量如何定义一致性边界?为什么"一个事务只能修改一个聚合"?

  • 聚合作为一致性边界的含义
  • 跨聚合事务的问题
  • 依赖领域事件协调跨聚合一致性

聚合的边界即一致性边界(invariant boundary),聚合内部的不变量在单个事务内保证。规则"一个事务只能修改一个聚合"意味着每个事务只加载并修改一个聚合根,事务提交时该聚合不变量得到保证。跨聚合的约束不应放进同一个事务,否则会引入分布式事务、锁竞争与耦合。跨聚合的一致性通常通过领域事件进行最终一致协调:聚合 A 变更后发布事件,订阅方聚合 B 再更新自己的状态。

这条规则把"强一致"限定在聚合内部,把"最终一致"留给聚合之间。这样既避免了横跨多个聚合的大事务(性能与锁问题),又保持了聚合内部的一致性。违反该规则会导致聚合边界形同虚设、事务失控。

#
★★

10. Domain Event 作为聚合间的最终一致性载体

Domain Event 如何作为聚合间最终一致性的载体?

  • 领域事件在跨聚合协调中的角色
  • 发布与订阅的异步解耦
  • 最终一致性的实现

当一个聚合修改导致另一聚合需要响应时,发起方聚合在其状态变更后发布一个领域事件(如 OrderPlaced),事件描述"已经发生的事实"。订阅方聚合(如 Inventory)监听该事件并更新自己的状态。发起方与响应方不在同一事务内,各自维护自己的事务边界,从而实现了聚合间的最终一致性。领域事件连接了"一个聚合的变更"与"另一个聚合的后续动作",使聚合之间解耦、不互相持有引用。

领域事件是聚合间协作的"粘合剂",它把"强一致"需求转化为"异步最终一致"的机制,避免横跨聚合的大事务。事件方向是单向的(发起方不等待响应方),关系通过事件而非对象引用表达,降低耦合。需要配合事务性发件箱保证事件可靠投递。

#
★★

11. 聚合内对象图的「引用深度」治理中超过多少层嵌套应拆分为独立聚合,如何用架构测试检测跨聚合直接修改内部状态的反模式?

聚合内对象图的引用深度如何治理?超过多少层嵌套应拆分为独立聚合?如何用架构测试检测"跨聚合直接修改内部状态"的反模式?

  • 对象图引用深度的治理
  • 深嵌套拆分为独立聚合的判断
  • 用架构测试(如 ArchUnit)防护跨聚合修改

聚合内对象图引用深度应控制在合理范围(通常建议不超过 3-4 层),过深的对象图意味着聚合过大、加载与一致性开销高、认知负担重。当聚合需要递归加载多层对象、对象间无强一致需求或边界模糊时,应拆分为独立聚合。为检测"跨聚合直接修改内部对象"的反模式,可用架构测试(如 ArchUnit)声明规则:聚合根的命令方法(public)是唯一允许被外部调用的入口,聚合内部实体的方法不允许被聚合外部包引用。例如 classes().that().resideInAPackage("..order..") 不得访问内部实体类的可变方法,从而在运行时用测试守护边界。

引用深度治理的本质是控制聚合规模与边界清晰度。深对象图是"上帝聚合"的征兆。架构测试把"只能通过聚合根访问"这一设计约束固化成可自动执行的规则,任何人违反即测试失败,防止后续演进中边界被破坏。

// ArchUnit:禁止聚合外的类直接调用聚合内部实体的方法
@ArchTest
static final ArchRule no_external_modification_of_internal_entities =
    classes().that().resideInAPackage("..order..")
        .and().haveSimpleNameEndingWith("Internal")
        .should().onlyBeAccessed().byClassesThat()
        .resideInAPackage("..order..");
#
★★

12. 值对象的业务不变量封装中 Money 的币种与金额组合、日期区间的包含边界、地址格式校验如何封装为自校验的值对象?

值对象如何把业务不变量封装为"自校验"?例如 Money 的币种与金额、DateRange 的包含边界、Address 的格式校验?

  • 自校验值对象的构造校验
  • 不同值对象的业务不变量
  • 值对象操作对不变量的一致性保证

自校验值对象把"合法状态"的校验封装在构造或工厂中,构造时即拒绝非法状态,从而保证任意合法实例都满足不变量。Money 校验:金额非 null、非负(或按业务)、币种合法,且运算时校验币种一致。DateRange 校验:end 必须晚于 start,contains/overlaps 方法保证边界语义一致。Address 校验:街道、邮编等字段非空、格式符合规则(如邮编正则、长度限制)。全部校验在构造时完成,之后由于不可变,不变量永久成立。

"自校验"的设计哲学是"非法状态无法被构造出来",把校验逻辑从使用方集中到值对象自身,避免每个调用方重复校验且容易遗漏。通过构造器/静态工厂抛出 IllegalArgumentException 或领域异常,把不变量内聚在值对象中,提升安全性。

public final class DateRange {
    private final Instant start;
    private final Instant end;

    public DateRange(Instant start, Instant end) {
        if (start == null || end == null) throw new IllegalArgumentException();
        if (end.isBefore(start)) throw new IllegalArgumentException("end 必须不早于 start");
        this.start = start;
        this.end = end;
    }

    public boolean contains(Instant t) {
        return !t.isBefore(start) && t.isBefore(end);
    }
}
#
★★

13. 值对象的持久化映射中嵌入式列、JSON 字段与 ORM 的配合,不可变值对象如何重建?

值对象如何持久化?嵌入式列、JSON 字段与 ORM 如何配合?不可变值对象如何从数据库重建?

  • 值对象的持久化可选方案
  • ORM 的 Component/Embedded 支持
  • 不可变值对象的重建机制

值对象持久化有几种方案:一是嵌入式列(Embedded/Component),把值对象字段平铺到宿主表的多个列(如 Address 的 street/city 各占一列),语义清晰、可查询;二是 JSON 字段,把整个值对象序列化为一个 JSON 列,适合复杂嵌套或灵活结构,但查询能力弱。ORM 实现上,JPA 用 @Embeddable/@Embedded 映射嵌入式列,MyBatis 用 resultMap 映射。不可变值对象重建有多种方式:JPA 的 @Embeddable 需要无参构造器,可配合包级私有的无参构造器与字段级访问(field access)把值直接注入;也可用静态工厂/反序列化机制(如 JSON 反序列化器)经由构造器重建。关键是让 ORM 能实例化不可变对象,同时不破坏其不可变语义。

持久化方案取决于"查询需求"与"结构复杂度":需要按字段查询用嵌入式列,结构多变或聚合便捷用 JSON。不可变对象与 ORM 的"无参构造 + 字段注入"存在冲突,需通过字段级访问、包级构造器或反序列化器调和,保证重建后仍是不可变合法实例。

#
★★

14. 值对象内禁止持有实体引用中身份泄漏与共享可变状态的危害,替代方案如何设计?

为什么值对象内禁止持有实体引用?身份泄漏与共享可变状态有什么危害?替代方案如何设计?

  • 值对象持有实体引用的危害
  • 身份泄漏与共享可变状态
  • 替代方案(用 ID 或值)

值对象是"无身份、不可变、可共享"的,若在其中持有实体引用,会把实体身份泄漏进值对象,破坏值对象"无身份"的定义;同时,值对象被多个聚合共享时,若它引用了可变实体,就会形成共享可变状态——一个聚合修改实体后,持有同一引用的其他值对象/聚合被意外改变,产生难以排查的 bug。替代方案:值对象需要关联到某实体时,应持有该实体的 ID(标识符,也是一种值对象)而非对象引用;需要实体的某些属性时,应把该属性拷贝为值对象的值,而不是持有实体引用。这样保持值对象不可变、可安全共享。

这条规则是"值对象完整性"的保障。持有实体引用会悄悄把值对象变成可变、可共享危险状态的容器。改为持有 ID 或拷贝值,既保留关联信息,又保持值对象不可变、可复制、可共享。

#

15. 聚合内不变量校验的时机(创建时 vs 命令方法内 vs 领域事件后)

聚合内不变量校验的时机有哪些?创建时、命令方法内、领域事件后校验各自的适用场景?

  • 不变量校验的多个时机
  • 各时机的适用场景
  • 校验逻辑的内聚位置

聚合内不变量校验的时机主要有三处:其一,创建时(构造器/工厂内)校验初始状态是否满足不变量,防止非法聚合被构造出来;其二,命令方法内校验,即每个业务方法(如 addItem、changeStatus)在变更前校验前置条件、变更后校验后置条件,保证聚合状态演化始终合法;其三,领域事件后校验,用于聚合间或异步流程中,当某聚合收到其他聚合的事件后,在更新自己状态前校验由事件带来的状态是否满足自身不变量。一般情况下应在创建时和命令方法内做主力校验,领域事件后校验用于跨聚合的最终一致场景。

校验时机应尽量前置:能在创建时校验的不要拖到命令方法,能在命令方法内同步校验的不要依赖异步事件后。这样能把非法状态尽早拒绝,错误定位更清晰。领域事件后校验只用于确需异步协调的场景,且要处理"事件顺序异常"导致的暂时不一致。

#

16. 聚合的设计中聚合根与边界的一致性?

聚合根与聚合边界如何保持一致?聚合的设计原则是什么?

  • 聚合根与边界的关系
  • 聚合的自治性
  • 聚合根作为唯一入口

聚合根与聚合边界保持一致意味着:聚合根决定了聚合的边界,聚合的所有对象都通过聚合根访问,聚合根是外部与聚合内部协作的唯一通道。聚合边界内的对象必须逻辑内聚、必须一起变更、共享一致的不变量。设计时,聚合应自治——内部对象不向外部暴露可变引用,聚合根暴露命令方法,聚合内部状态变化通过聚合根完成。聚合根的一致性体现在:边界清晰、外部无直接访问内部对象的途径、跨聚合依赖通过 ID 与事件表达。

聚合根是"边界的门面"。边界一致性的核心是"外部只能通过根访问",这样聚合内部的不变量才能被根统一守护。设计时应先确定"哪些对象必须同时变更保持一致",据此划定边界,并让聚合根承载全部对外命令。

#

17. 聚合的持久化中仓储与工作单元?

聚合如何持久化?仓储(Repository)与工作单元(Unit of Work)扮演什么角色?

  • 聚合持久化的入口
  • 仓储与工作单元的职责
  • 事务边界与持久化

聚合通过仓储(Repository)持久化,仓储负责把聚合(内存中的领域对象)转成持久化形式并加载回内存,应用程序不直接触碰基础设施。工作单元(Unit of Work)跟踪聚合在事务内的变更,在事务提交时统一把聚合的修改持久化到数据库,保证"一个事务内的聚合变更要么全部落库要么全部回滚"。仓储与聚合根一一对应(一个聚合一个仓储),工作单元通常由 ORM 框架(如 JPA 的 EntityManager、Hibernate 的 Session)或应用层事务管理提供。聚合的持久化策略是"整体加载、整体保存",聚合根即持久化根。

仓储把"聚合的集合语义"与"对象-关系映射"解耦,让领域层不依赖数据库。工作单元把聚合在事务内的变更集中提交,协调事务边界。二者配合保证了聚合作为一个整体被持久化,且事务边界清晰。

#

18. 聚合的一致性与并发中乐观锁?

聚合的一致性与并发如何保证?乐观锁(Optimistic Locking)如何应用?

  • 多用户并发修改聚合
  • 乐观锁机制
  • 版本号与冲突处理

聚合在并发场景下可能被多个事务同时修改,破坏一致性。乐观锁通过版本号(version)或时间戳实现:聚合持久化时记录版本号,更新时校验当前版本与读取时一致,若不一致说明已被他人修改则拒绝本次更新(抛并发异常),由应用层决定重试或提示用户。JPA 用 @Version 字段自动实现乐观锁,MyBatis 可用 @Version 或自定义 SQL 的 WHERE version=? 实现。乐观锁适合并发冲突较少的场景,代价是极端并发下部分更新失败需重试。

乐观锁是"聚合一致性 + 并发"的经典解法:不锁库,靠版本号在校验时发现冲突。它避免了悲观锁的锁开销与死锁风险,适合大多数业务场景。聚合的版本号通常放在聚合根上,每次变更递增。

#

19. 值对象集合的不可变性中返回只读视图或拷贝,防止外部修改破坏不变量?

值对象内的集合如何保持不可变性?为什么返回只读视图或拷贝?

  • 集合字段的不可变封装
  • 返回只读视图 vs 拷贝
  • 防止外部修改破坏不变量

值对象内的集合字段虽是 final,但集合本身可变,若直接把内部集合引用暴露给外部,外部即可增删元素,破坏值对象的不变量与不可变性。因此应在构造时把集合拷贝为不可变集合(如 Collections.unmodifiableList 或 List.copyOf),并且 getter 返回只读视图或副本,绝不返回内部引用。这样外部既不能修改内部集合,值对象内部也无法被外部无意篡改,保证值对象始终处于合法状态。

"final 引用"不等于"内容不可变",很多不可变性 bug 源于把可变集合直接暴露。防御性拷贝(defensive copy)或返回不可变视图是值对象不可变性的最后一道防线,尤其当集合包含值对象时,外部修改会破坏集合语义。

public final class LineItems {
    private final List<Money> items;
    public LineItems(List<Money> items) {
        this.items = List.copyOf(items); // 构造时拷贝为不可变列表
    }
    public List<Money> items() {
        return items; // 已是不可变列表,外部无法修改
    }
}