前言
战略设计解决了“边界在哪”的问题,这一篇开始我们要看边界内的“砖块”——实体和值对象。
它们是 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;
// ...
}
}
实体的关键点:
- 业务行为写在实体类里(充血模型)。
- 跨多个实体的业务逻辑放在领域服务里(不在实体里)。
值对象的关键点:
- 属性
final,不可变。 - 想“修改”地址?新建一个 Address 整体替换。
阶段 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 -- 序列化整个地址
);
适用场景:
- 值对象嵌套复杂。
- 业务上地址整体读、整体写。
- 不需要按地址字段做查询。
五、什么时候用值对象?
不是所有属性集合都该抽成值对象。要看是否满足以下特征:
- 概念完整:这组属性描述的是一个完整的概念(地址 = 省+市+区+街道+邮编)。
- 不可变:修改时整体替换而不是改单个字段。
- 独立无意义:脱离实体后没有业务价值。
- 可相等性比较:两个实例字段相同就视为相等。
如果符合,用值对象。如果不符合(比如地址需要单独管理、经常改、还要按省/市做分析),那就应该升级为实体。
同一个对象在不同上下文里可以是不同形态。在“学员收货信息”上下文里,地址是值对象;在“行政区划维护”上下文里,地址是实体。
六、值对象的优势和局限
优势
- 简化数据库设计:减少表的数量。
- 提升性能:少 JOIN。
- 业务概念完整:地址作为一个整体概念出现。
- 不可变带来的线程安全:值对象天然线程安全。
局限
- 不能按值对象的字段做复杂查询(除非用 JSON 字段 + 索引)。
- 如果一个实体嵌入的值对象太多,会导致这个实体的字段臃肿。
- 如果值对象需要独立演化(比如地址库),应该升级为实体。
七、实战的代码模板
一个推荐的代码组织方式:
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 studentId 和 long 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);
}
}
总结
这一篇我们讲了:
- 实体看“我是谁”,值对象看“我是什么”。
- 实体有 ID 可变,值对象无 ID 不可变。
- 值对象在数据库里通常嵌入实体表存储。
- 同一个对象在不同上下文里可以是不同形态。
下一篇我们讲聚合与聚合根——一个完整的业务操作涉及多个对象时,应该怎么组织。