Skip to content
第 4 章 架构 ⏱ 11 分钟阅读

第 4 章:实体与值对象 ​

学习目标 ​

  • 区分实体和值对象的本质差异
  • 学会判断业务对象该用实体还是值对象
  • 掌握值对象不可变的设计原则
  • 避开"万能实体"和"过度抽象"的坑

一、实体 vs 值对象 ​

维度实体值对象
唯一标识必须有(ID)无 ID
可变性可变不可变
相等性用 ID 比较用值比较
生命周期独立,跨时间跟踪随所有者消亡
例子User、OrderMoney、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 跨聚合引用作为实体的属性
数量少,核心业务对象数量多,辅助属性

动手练习 ​

  1. Money 拓展:实现 divide(Money, int scale) 除法和 greaterThan(Money)
  2. DateRange 实战:写 overlap(DateRange other) 判断两个时间窗口是否重叠
  3. ID 改造:把 Long userId 改为 UserId 值对象,加上创建时校验非空
  4. 识别 VO:挑一个你做过的业务,列出 5 个可以提取的值对象

下一章:第 5 章:聚合根 →

本站基于 VitePress 构建 · 由 StackHub 团队维护