第 4 章:实体与值对象
学习目标
- 区分实体和值对象的本质差异
- 学会判断业务对象该用实体还是值对象
- 掌握值对象不可变的设计原则
- 避开"万能实体"和"过度抽象"的坑
一、实体 vs 值对象
| 维度 | 实体 | 值对象 |
|---|---|---|
| 唯一标识 | 必须有(ID) | 无 ID |
| 可变性 | 可变 | 不可变 |
| 相等性 | 用 ID 比较 | 用值比较 |
| 生命周期 | 独立,跨时间跟踪 | 随所有者消亡 |
| 例子 | User、Order | Money、Address、DateRange |
java
// 实体:有 ID,生命周期独立
public class Customer {
private final CustomerId id; // 唯一标识
private String name; // 可变
private String email;
private Address address; // 值对象作为属性
}
// 值对象:无 ID,值相等
public record Address(String province, String city, String detail) {
public Address {
if (province == null || city == null) {
throw new IllegalArgumentException("省市必填");
}
}
}⚠️ 坑 1:把
Address当实体,加addressId自增主键。地址是"如果值一样就视为同一个",不必关心是第几条记录。值相等就是同一份。
二、值对象不可变性的实战
java
public final class DateRange {
private final LocalDate start;
private final LocalDate end;
public DateRange(LocalDate start, LocalDate end) {
if (start.isAfter(end)) {
throw new IllegalArgumentException("开始日期不能晚于结束");
}
this.start = start;
this.end = end;
}
public DateRange extend(LocalDate newEnd) {
// 不修改自己,返回新对象
return new DateRange(start, newEnd);
}
public boolean contains(LocalDate date) {
return !date.isBefore(start) && !date.isAfter(end);
}
public long days() {
return ChronoUnit.DAYS.between(start, end);
}
}使用方式:
java
DateRange promotion = new DateRange(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31));
DateRange extended = promotion.extend(LocalDate.of(2026, 2, 28));
// promotion 仍然指向 1 月,extended 指向 1月-2月⚠️ 坑 2:值对象加
setter就不"值对象"了,变成可变对象。任何修改都返回新对象,杜绝脏数据。
三、用 ID 还是对象引用?
跨聚合只用 ID 引用,聚合内直接对象引用:
java
// 订单聚合
public class Order {
private final OrderId id;
private final UserId buyerId; // 跨聚合,用 ID
private final List<OrderItem> items; // 聚合内,直接对象
}
// 商品聚合(独立)
public class Product {
private final ProductId id;
private final String name;
private final Money price;
}为什么不直接引用 Buyer?因为订单和用户生命周期不同:
java
// 用 ID 引用的好处
// 1. 避免循环依赖
// 2. 可以在不同聚合里维护各自的"快照"
// 3. 后期拆微服务,改成 RPC 调用即可⚠️ 坑 3:订单里放
private Buyer buyer,导致每次查订单都连表查用户,改用户信息时订单还显示旧数据。用户改名后订单应该冻结当时昵称。
四、Money 值对象实战
java
public final class Money {
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
this.amount = amount.setScale(2, RoundingMode.HALF_UP);
this.currency = currency;
}
public static Money of(String amount, String currency) {
return new Money(new BigDecimal(amount), currency);
}
public static Money zero(String currency) {
return new Money(BigDecimal.ZERO, currency);
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
public Money subtract(Money other) {
requireSameCurrency(other);
return new Money(amount.subtract(other.amount), currency);
}
public Money multiply(BigDecimal factor) {
return new Money(amount.multiply(factor), currency);
}
public boolean isPositive() {
return amount.signum() > 0;
}
public long toFen() {
return amount.multiply(BigDecimal.valueOf(100)).longValueExact();
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new CurrencyMismatchException(currency, other.currency);
}
}
// equals/hashCode 用所有字段
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money m)) return false;
return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount.stripTrailingZeros(), currency);
}
}⚠️ 坑 4:
new BigDecimal(0.1)是0.1000000000000000055...,永远用BigDecimal(String)或BigDecimal.valueOf(0.1)。
五、识别业务里的值对象
业务里有大量天然的值对象,留意以下场景:
java
// 联系方式
public record Contact(String phone, String email) {}
// 身份证
public record IdCard(String number, LocalDate issueDate) {}
// 坐标
public record GeoPoint(double longitude, double latitude) {}
// 时间窗口
public record TimeWindow(LocalDateTime start, LocalDateTime end) {}判断标准:两个属性放一起才有意义,单独拆开用没意义 → 值对象。
本章小结
| 实体 | 值对象 |
|---|---|
| 有 ID,生命周期独立 | 无 ID,值相等 |
| 可变,封装状态变更 | 不可变,修改返回新对象 |
| 用 ID 跨聚合引用 | 作为实体的属性 |
| 数量少,核心业务对象 | 数量多,辅助属性 |
动手练习
- Money 拓展:实现
divide(Money, int scale)除法和greaterThan(Money) - DateRange 实战:写
overlap(DateRange other)判断两个时间窗口是否重叠 - ID 改造:把
Long userId改为UserId值对象,加上创建时校验非空 - 识别 VO:挑一个你做过的业务,列出 5 个可以提取的值对象
下一章:第 5 章:聚合根 →