跳至正文
来两杯美式
返回

DDD 复杂业务系统设计(四):实体与值对象,把“学员”和“地址”放进代码的两种姿势

By 来两杯美式
发布于

前言

战略设计解决了“边界在哪”的问题,这一篇开始我们要看边界内的“砖块”——实体和值对象。

它们是 DDD 战术设计里最基础的两个概念,搞清楚它们,后面所有的东西都顺了

一、什么是实体?

实体(Entity):有唯一标识,并且标识在状态变化后仍然保持一致的对象。

最典型的实体:学员

class Student {
    StudentId id;          // 唯一标识
    String name;
    String email;
    LocalDateTime joinedAt;
}

不管这个学员改了多少次名字、换了多少次邮箱,他的 StudentId 不变,他还是同一个学员

实体的核心是“标识”,不是“属性”

二、什么是值对象?

值对象(Value Object)没有标识,通过属性值来识别的对象。

最典型的值对象:地址

class Address {
    String province;
    String city;
    String district;
    String street;
    String zipCode;
}

两个地址如果字段完全一样,它们就是同一个地址。地址没有 ID,也不需要 ID

值对象的核心是“一组不可变的属性”,是“值的整体”。

三、两者最本质的区别

用一个对比表说清楚:

维度实体值对象
唯一标识
可变性可变(属性会变)不可变(整体替换)
相等性判断通过 ID通过属性值
生命周期有(创建→修改→删除)无(用完即扔)
持久化独立存储嵌入实体或序列化存储
业务逻辑包含丰富的行为基本只承载数据

一句话总结:实体看“我是谁”,值对象看“我是什么”。

四、四个阶段的形态

在 DDD 的不同阶段,实体和值对象的形态是不一样的。我用“学员”和“地址”走一遍四个阶段。

阶段 1:业务形态

学员是业务中的核心对象,有完整的业务行为:

地址只用于描述学员的所在地,几乎没有自己的业务行为。它就是学员的一个属性集合。

阶段 2:代码形态

// 实体:充血模型,包含业务行为
class Student {
    StudentId id;
    String name;
    Address homeAddress;  // 值对象
    List<CourseEnrollment> enrollments = new ArrayList<>();

    void enrollIn(Course course) { /* 选课业务 */ }
    void submitHomework(Homework hw) { /* 提交作业 */ }
    void withdrawFrom(Course course) { /* 退课业务 */ }
}

// 值对象:贫血模型,几乎只有数据
class Address {
    final String province;
    final String city;
    final String district;
    final String street;
    final String zipCode;

    Address(String province, String city, ...) {
        this.province = province;
        this.city = city;
        // ...
    }
}

实体的关键点

值对象的关键点

阶段 3:运行形态

实体作为领域对象(DO)在内存中存在。多次修改后,ID 不变,仍然是同一个实体。

值对象作为实体的属性存在。多个值对象嵌套、组合,形成实体的完整状态。

Student s1 = new Student(new StudentId(1), "张三", new Address("北京", "海淀", ...));
// 修改地址
s1.changeHomeAddress(new Address("上海", "浦东", ...));  // 整体替换
// s1 还是同一个 Student,ID 没变

阶段 4:数据库形态

这是新手最容易纠结的地方。

传统范式设计

CREATE TABLE student (id BIGINT PRIMARY KEY, name VARCHAR);
CREATE TABLE student_address (
    id BIGINT PRIMARY KEY,
    student_id BIGINT REFERENCES student(id),
    province VARCHAR, city VARCHAR, ...
);

每个对象一张表,主从表分开。问题:表多、JOIN 多、模型和代码不一致。

值对象的数据库策略有两种

方式 1:属性嵌入(推荐)

值对象的属性直接合并到实体的表里:

CREATE TABLE student (
    id BIGINT PRIMARY KEY,
    name VARCHAR,
    home_province VARCHAR,    -- 值对象属性
    home_city VARCHAR,
    home_district VARCHAR,
    home_street VARCHAR,
    home_zip_code VARCHAR
);

优点

缺点

方式 2:序列化大对象

CREATE TABLE student (
    id BIGINT PRIMARY KEY,
    name VARCHAR,
    home_address JSON    -- 序列化整个地址
);

适用场景

五、什么时候用值对象?

不是所有属性集合都该抽成值对象。要看是否满足以下特征:

  1. 概念完整:这组属性描述的是一个完整的概念(地址 = 省+市+区+街道+邮编)。
  2. 不可变:修改时整体替换而不是改单个字段。
  3. 独立无意义:脱离实体后没有业务价值
  4. 可相等性比较:两个实例字段相同就视为相等。

如果符合,用值对象。如果不符合(比如地址需要单独管理、经常改、还要按省/市做分析),那就应该升级为实体

同一个对象在不同上下文里可以是不同形态。在“学员收货信息”上下文里,地址是值对象;在“行政区划维护”上下文里,地址是实体。

六、值对象的优势和局限

优势

局限

七、实战的代码模板

一个推荐的代码组织方式:

com.example.learning
├── domain
│   ├── student              # 学员上下文
│   │   ├── Student.java     # 实体
│   │   ├── StudentId.java   # 实体 ID(值对象)
│   │   ├── Address.java     # 值对象
│   │   └── CourseEnrollment.java  # 实体
│   └── ...

实体 ID 本身也用值对象实现(包装一个 Long):

class StudentId {
    final long value;
    StudentId(long value) { this.value = value; }

    @Override
    public boolean equals(Object o) {
        return o instanceof StudentId && ((StudentId) o).value == this.value;
    }
    // hashCode 略
}

这样能避免原生类型满天飞(避免“long studentIdlong courseId 混用”这种 bug)。

八、常见误区

误区 1:所有对象都设计成实体

很多团队写代码全用实体,全是 Long 主键,完全没有值对象。这会让模型变成“只有骨头没有肉”,缺乏业务表达力。

误区 2:值对象当成 DTO

值对象是领域层的概念,不是数据传输对象。DTO 是用户接口层的东西,两者不能混

误区 3:值对象可变

值对象一旦可变,就失去了“值”的意义。值对象必须不可变

误区 4:把充血模型做成“贫血 + Service”

// ❌ 错误:把业务逻辑全塞 Service,实体只是数据
class Student {
    long id;
    String name;
    // 只有 getter/setter
}
class StudentService {
    void enroll(Student s, Course c) { /* 业务逻辑全在这 */ }
}

// ✅ 正确:业务逻辑在实体里
class Student {
    void enrollIn(Course c) { /* 业务逻辑在实体里 */ }
}
class EnrollStudentService {  // 只做编排
    void execute(StudentId sid, CourseId cid) {
        Student s = studentRepo.find(sid);
        Course c = courseRepo.find(cid);
        s.enrollIn(c);  // 调用实体的业务方法
        studentRepo.save(s);
    }
}

总结

这一篇我们讲了:

下一篇我们讲聚合与聚合根——一个完整的业务操作涉及多个对象时,应该怎么组织。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.DDD 复杂业务系统设计(一):为什么从“领域”开始,比从“数据库”开始更靠谱
  2. 02.DDD 复杂业务系统设计(二):领域与子域,把“学校/课程/学员”拆成三件事再说
  3. 03.DDD 复杂业务系统设计(三):限界上下文,为什么“课程”在教务和市场是两种东西
  4. 04.DDD 复杂业务系统设计(四):实体与值对象,把“学员”和“地址”放进代码的两种姿势
  5. 05.DDD 复杂业务系统设计(五):聚合与聚合根,一个“选课单”应该装多少东西
  6. 06.DDD 复杂业务系统设计(六):领域事件,让“作业提交”自动触发批改进度
  7. 07.DDD 复杂业务系统设计(七):分层架构,你的“业务代码”应该待在哪一层
  8. 08.DDD 复杂业务系统设计(八):整洁架构 vs 六边形架构 vs DDD 分层,我选哪一种
  9. 09.DDD 复杂业务系统设计(九):中台,在线教育平台到底应该共享什么
  10. 10.DDD 复杂业务系统设计(十):从战略到微服务,一张图跑通 DDD+中台+微服务
  11. 11.DDD 复杂业务系统设计(总结):从领域到微服务,一张图看懂 DDD 落地全流程

上一篇
设计模式之工厂模式:简单工厂、工厂方法与抽象工厂
下一篇
ZooKeeper 原理与实践:数据模型、Watcher 与 ZAB 协议