值对象案例

wen java案例 3

从领域建模到企业级实践,彻底告别贫血模型

目录导读

  1. 值对象定义与本质特征 – 为何它不同于实体?
  2. 经典业务案例拆解 – 地址、金额与时间段的三重奏
  3. 代码实现对比 – 贫血模型 vs 充血模型
  4. 持久化映射策略 – JPA与MyBatis的最佳实践
  5. 常见陷阱与性能优化 – 不可变性的代价与补偿
  6. FAQ问答 – 解决你最棘手的5个问题

值对象核心定义:不可变性的力量

在领域驱动设计(DDD)中,值对象(Value Object, VO)是通过属性值本身来定义的对象,它没有唯一标识符(ID),与实体(Entity)拥有生命周期和身份连续性不同,值对象描述的是“是什么”,而不是“是哪个”

值对象案例

三大铁律

  • 不可变性(Immutability):创建后状态不可修改,任何变更都产生新实例
  • 值相等性(Value Equality):当所有属性值相等时,两个VO即视为相同
  • 自包含行为(Self-Encapsulated):将业务规则内聚在对象自身方法中

典型例子:Money(金额+货币)、Address(街道+城市+邮编)、DateRange(开始+结束日期)。


高价值业务场景案例拆解

案例1:电商订单地址管理(最经典)

// 错误示范:将地址当作实体,带ID,可修改
@Entity
public class AddressEntity {
    @Id private Long id;
    private String street;
    private String city;
    // setter方法... 可任意修改
}
// 正确姿势:值对象
@Value
public class Address {
    String street;
    String city;
    String zipCode;
    // 无setter,构造器校验
    public Address(String street, String city, String zip) {
        if (street.isBlank() || city.isBlank() || !zip.matches("\\d{5}")) {
            throw new IllegalArgumentException("非法地址");
        }
        this.street = street; this.city = city; this.zip = zip;
    }
}

业务收益:订单历史地址快照不会因用户后续修改配送地址而改变,确保审计和物流追溯的准确性。

案例2:金融交易中的金额计算

public class Money {
    private final BigDecimal amount;
    private final Currency currency;
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) 
            throw new IncompatibleCurrencyException();
        return new Money(amount.add(other.amount), currency);
    }
    // equals() 比较金额和货币
}

核心价值消灭浮点误差(double运算0.1+0.2≠0.3),强制同币种校验,并让业务代码直接调用money.add()而非操作裸数字。

案例3:预订系统的日期区间

public class DateRange {
    private final LocalDate start;
    private final LocalDate end;
    public boolean overlaps(DateRange other) { ... }
    public long daysBetween() { ... }
}

所有判断逻辑内聚,避免在服务层散落一堆if (date1.before(date2))这种坏味道。


代码实现对比:为什么说“贫血模型”是反模式?

维度 贫血模型(Getter/Setter) 值对象充血模型
业务规则 散落在Service层,重复且易漏 集中在VO内部,强制约束
数据一致性 任意外部修改,容易产生非法状态 构造时校验,永不可变
可测试性 需要Mock大量依赖 纯函数式,单元测试简单
代码体积 Service层膨胀到上千行 服务层瘦身,职责清晰

实际痛点:某电商系统在用贫血模型时,订单金额计算逻辑在3个Service中重复写,导致一次汇率调整漏改了一处,产生40万元损失,重构为Money值对象后,所有计算收敛到一处。


持久化映射:打破关系数据库的尴尬

JPA(Spring Data)策略

@Embeddable
public class Address { ... }
@Entity
public class Order {
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "street", column = @Column(name = "ship_street")),
        @AttributeOverride(name = "city", column = @Column(name = "ship_city"))
    })
    private Address shippingAddress; // 单表存储,无外键
}

MyBatis自定义TypeHandler

实现TypeHandler<Money>,将金额+币种映射为两列,或者序列化为JSON字符串。

性能建议:值对象常不可变,适合二级缓存,但注意ORM的脏检查(Dirty Checking)会忽略不可变对象,需配置@Immutable


常见陷阱与优化方案

陷阱 后果 解决策略
过度使用 每个小属性都做VO,导致类爆炸 聚合相关属性(如经纬度合并成GeoPoint
传递性风险 持有可变实体引用(如List 使用Collections.unmodifiableList()或深拷贝
序列化每次重建开销 高频调用场景性能损耗 使用record(Java 14+)或Lombok生成轻量对象
数据库关联障碍 想通过外键关联VO 放弃VO身份,改为JSON或嵌入式列

FAQ问答精选

Q1:值对象可以有方法吗?当然可以,但允许修改内部状态吗? A:绝对不能,所有方法应返回新实例或布尔判断,例如money.add()返回新Money,而不是改变现有金额。

Q2:什么时候应该将一个概念建模为值对象而非实体? A:判断核心标准:两个对象属性相同但拥有不同ID,你对它们是否“一视同仁”? 如果答案是“是”,则应为值对象,两个人地址都是“北京市朝阳区1号”,它们就是同一个地址;但两个用户ID不同,绝不能视为同一人。

Q3:值对象如何与数据库主键“和平共处”? A:值对象不需要主键,持久化时,要么嵌入到实体的表中作为列组,要么使用序列化为JSON,绝不单独建表设ID。

Q4:如果业务需要“修改地址”怎么办? A:直接创建一个新的Address实例,并替换实体的引用,而不是修改原有地址对象。order.changeShippingAddress(newAddress),这天然支持了历史快照。

Q5:对于跨越微服务的值对象,如何保证一致性? A:服务间传输时,使用DTO(数据传输对象)复刻值对象结构,并附带版本号,消费方通过fromDTO()重建不可变值对象。


重构一步,领先一步

在企业级系统里,从“长满Setter的实体”走向“小而美的值对象”,是领域建模成熟度的分水岭,这不仅仅是代码风格的改变,更是对业务语言(Ubiquitous Language)的尊重。下一次当你在Service层写出一长串校验逻辑时,请停下来思考:这些规则,是否应该存在于一个名为XXX的值对象内? 正确运用值对象,你的代码将更健壮、更易测试,也更贴近业务的真实世界。

上一篇实体案例

下一篇聚合案例

抱歉,评论功能暂时关闭!