写这篇时我换了个姿势:不把自己当背书机器,就当给组里新人讲完一轮之后,把当场被问住、后来又想明白的那几句记下来。
八年 Java 做下来,我对 DDD 的态度很务实:它能帮我们把「业务话」和「代码形状」对齐;至于目录叫
domain还是core,那是团队习惯,别本末倒置。
下面串两篇小册主题:02 领域(Domain)里到底装什么,03 聚合 / 实体 / 值对象怎么分工。中间我故意埋了几个「你可能会愣一下」的问题——当年我也愣过。
开篇先对齐:DDD 到底是谁提出来的?
领域驱动设计(Domain-Driven Design,DDD) 是一种软件设计方法,Eric Evans 在《Domain-Driven Design: Tackling Complexity in the Heart of Software》里把它系统化。
一句话:把复杂系统的设计重心,从「技术表结构」拽回「业务问题本身」——软件要解决的,是领域(Domain)里的规则和知识,不是数据库里那张表叫啥。
02|Domain(领域)是什么?领域层都包含什么?
概念:领域不是文件夹名
在 DDD 里,领域(Domain) 指的是:这块业务里共有的知识、规则、数据约束和做事方式的集合。
它是软件要扎根的环境,往往还能拆成多个子领域(Subdomain)——比如电商里「下单」「支付」「物流」各是一块,各有一套说法和规则,硬揉成一个类迟早出事。
我自己的大白话:领域就是「这摊生意在真实世界里怎么运转」在脑子里的投影;代码里的 domain 包,只是尽量把这投影摆整齐,摆不齐就继续改模型,别假装已经对齐。
特性:为什么总有人提「统一语言」
- 业务中心:讨论模型时,先问业务在说什么,再问表怎么建。
-
模型驱动:用实体、值对象、聚合、领域服务等把业务知识显式化,而不是散落在
if-else和注释里。 - 语言一致性(Ubiquitous Language,统一语言):产品、运营、开发用同一套词指同一件事——「订单关闭」到底算不算「退单」,在会议上吵清楚,比上线后撕需求单便宜。
- 边界清晰:子领域之间、聚合之间边界清楚,复杂度才拆得动。
用途:领域模型不是摆设
- 封装业务逻辑:规则集中在领域模型里,改规则有处可找。
- 沟通工具:白板上的模型业务能认,比只对程序员友好的分层名有用。
- 软件设计的基础:架构怎么切、事务画在哪,常常跟着聚合和上下文走。
实现手段:战术设计里那一串「积木」
书上常见的一套(英文第一次出现时括号带上,方便你翻原版):
| 手段 | 英文 | 一句话 |
|---|---|---|
| 实体 | Entity | 有唯一标识,生命周期里「还是它」 |
| 值对象 | Value Object | 没身份,靠值相等,常不可变 |
| 聚合 | Aggregate | 一致性边界 + 对外只认聚合根 |
| 领域服务 | Domain Service | 行为挂不到某一个实体上时,放这儿 |
| 领域事件 | Domain Event | 已发生的业务事实,用来松耦合联动 |
| 仓储 | Repository | 像集合一样存取聚合根,屏蔽持久化细节 |
| 领域适配器 | Domain Adapter / Port 实现 | 领域不被外部协议、SDK 污染的接法(常和六边形/整洁架构一起谈) |
| 工厂 | Factory | 创建复杂聚合或实体时,把拼装规则收拢 |
工厂这块我多说一句:OpenAI 对接、抽奖规则、会话模型选型这类「创建时分支多、规则多」的场景,用工厂把创建逻辑从业务方法里拽出来,可读性会好很多——不是炫技,是真省脑。
探究:你可能会问的三个问题
Q1:「领域层」和「应用层」到底谁写 if?
领域层承载业务规则与不变量(例如「已支付订单不能加明细」);应用层做用例编排(先调谁、事务从哪开、发不发消息)。别让应用层变成巨型脚本,也别让领域层去关心「这次 HTTP 返回 200 还是 400」——那是接口层的事。
Q2:领域适配器是不是就是 Spring 里的 @Adapter?
不必然。 DDD 里的适配器是角色:领域要的能力叫端口(Port),外面换供应商时用适配器(Adapter)接上。Spring 注解名各家项目不一样,心法是「依赖方向朝内」,别纠结类名必须叫啥。
Q3:子领域拆多了,会不会「过度设计」?
会。我的经验是:先能在统一语言下讲清一条主流程,再谈拆几个包;拆不动就别硬拆,否则新人进来只看到一堆空接口,比不拆还糟。
03|聚合、实体、值对象
领域模型(Domain Model)是对业务的抽象,聚合、实体、值对象是其中最常用的三块砖。下面按「概念 → 特性 → 用途 → 实现手段」走,代码保留示例结构,我顺手把类名、空格、笔误理了一遍,能直接当教学代码读。
聚合(Aggregate)
概念
聚合是一组强相关对象的集合,对外表现成一个单元。
关键句(建议背): 聚合内尽量 事务一致性;聚合之间往往用最终一致性(事件、消息、补偿)。
特性
- 一致性边界:聚合内部状态要一起合法,别出现「订单已付、明细却是空的」这种半吊子。
- 聚合根(Aggregate Root):唯一对外入口,外部不准绕过根去改内部实体。
- 事务边界:一个事务里改一个聚合通常最省心;跨聚合?想想事件和幂等。
用途
封装业务规则、降低模型复杂度、把「能一起改」的东西绑在一起。
实现手段(落地 checklist)
- 选好谁是根——能代表整坨业务、有稳定标识。
- 只通过根修改内部对象。
- 事务尽量对齐聚合边界;跨聚合用领域事件等。
- 规则写在聚合内,别漏到 Service 里偷偷判断。
- 持久化以聚合根为粒度去谈仓储(Repository)。
探究:一个聚合 = 一张表吗?
不一定。 聚合是模型概念;表是存储投影。常见是根 + 内部实体落库在有关联的一组表里,但别为了表结构反推聚合——先模型合理,再谈 ORM 怎么映射。
代码示例:订单聚合(简化)
下面示例展示:根封装规则,内部实体不对外暴露修改捷径。
// 实体基类
public abstract class BaseEntity {
protected Long id;
public Long getId() {
return id;
}
}
// 值对象基类(示例占位:值对象通常不可变,不通过 setter 暴露修改)
public abstract class ValueObject {
}
// 订单项:聚合内的实体
public class OrderItem extends BaseEntity {
private String productName;
private int quantity;
private double price;
public OrderItem(String productName, int quantity, double price) {
this.productName = productName;
this.quantity = quantity;
this.price = price;
}
public double getTotalPrice() {
return quantity * price;
}
// 省略 getter / setter
}
import java.util.ArrayList;
import java.util.List;
// 订单聚合根:外部只能通过它改订单
public class OrderAggregate extends BaseEntity {
private final List<OrderItem> orderItems;
private String customerName;
private boolean paid;
public OrderAggregate(String customerName) {
this.customerName = customerName;
this.orderItems = new ArrayList<>();
this.paid = false;
}
public void addItem(OrderItem item) {
if (!paid) {
orderItems.add(item);
} else {
throw new IllegalStateException("Cannot add items to a paid order.");
}
}
public double getTotalAmount() {
return orderItems.stream().mapToDouble(OrderItem::getTotalPrice).sum();
}
public void markAsPaid() {
if (getTotalAmount() > 0) {
paid = true;
} else {
throw new IllegalStateException("Order total must be greater than 0 to be paid.");
}
}
// 省略 getter / setter
}
public class OrderDemo {
public static void main(String[] args) {
OrderAggregate order = new OrderAggregate("xiaomaozhu");
order.addItem(new OrderItem("手机", 1, 1000.00));
order.addItem(new OrderItem("数据线", 2, 25.00));
System.out.println("Total amount: " + order.getTotalAmount());
order.markAsPaid();
}
}
这段代码想说明什么:
加明细、标记支付,都走聚合根;规则(未支付才能加、金额大于 0 才能付)锁在根里。你以后加「优惠券」「运费」,先问聚合还能不能扛,别在 Controller 里再写一遍 if。
实体(Entity)
概念
实体 = 唯一标识 + 状态 + 行为。
「是不是同一个用户」不看名字,看 id——标识在整个生命周期里稳定。
特性
- 唯一标识:业务里能区分「这一个」的东西。
- 领域标识 vs 技术标识:有时是业务号(订单号),有时是数据库自增 id——只要在全系统里能唯一定位,可以,但别用技术细节冒充业务概念糊弄自己。
- 行为与状态一起变:实体不只是 getter/setter 容器。
用途
表达业务对象(用户、订单、账号),封装规则,维护自己的一致性。
实现手段
定义类、保证 id、方法里写规则、ORM 映射、跨实体复杂协调可考虑领域服务、状态变更可发领域事件。
探究:equals 只比 id 够不够?
在聚合边界内,实体通常用 id 区分;值对象才比属性。混用会导致集合去重、缓存 key 全乱——这是真 bug,不是风格问题。
代码示例:用户实体
import java.util.Objects;
import java.util.UUID;
public class UserEntity {
private final UUID id;
private String name;
private String email;
public UserEntity(UUID id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public void updateName(String newName) {
this.name = newName;
}
public void updateEmail(String newEmail) {
this.email = newEmail;
}
public UUID getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
UserEntity user = (UserEntity) o;
return id.equals(user.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "UserEntity{"
+ "id=" + id
+ ", name='" + name + '\''
+ ", email='" + email + '\''
+ '}';
}
}
public class UserEntityDemo {
public static void main(String[] args) {
UserEntity user = new UserEntity(
UUID.randomUUID(),
"XiaoFuGe",
"xiaofuge@qq.com");
System.out.println(user);
user.updateName("XiaoFuGe");
user.updateEmail("xiaofuge@qq.com");
System.out.println(user);
}
}
值对象(Value Object)
概念
值对象 = 用一组属性描述的概念,没有「身份」;两个值对象属性全一样,就视为同一个值。
常不可变:要改?换一个新对象,别在原地改。
特性
不可变、按值相等、可替换、可复用;线程安全往往更好做。
用途
金额与货币、度量、区间、地址、状态枚举等——凡是「描述性」强、不需要单独追 id 的,都合适。
实现手段
私有字段、构造或工厂创建、equals / hashCode 按全部属性;需要落库时拆列或 JSON,别偷懒塞一个 varchar 里啥都往里倒(除非团队明确接受)。
探究:枚举算不算值对象?
可以算一种极简值对象——值域封闭、不可变。复杂规则(例如不同币种精度)再升级成类不迟。
代码示例:订单状态(枚举)
public enum OrderStatusVO {
PLACED(0, "下单"),
PAID(1, "支付"),
COMPLETED(2, "完成"),
CANCELLED(3, "退单");
private final int code;
private final String description;
OrderStatusVO(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
public static OrderStatusVO fromCode(int code) {
for (OrderStatusVO status : values()) {
if (status.getCode() == code) {
return status;
}
}
throw new IllegalArgumentException("Invalid code for OrderStatus: " + code);
}
}
存库可以只存 code,读出来用 fromCode 转回枚举,避免魔法数字满天飞。
代码示例:地址(不可变类)
import java.util.Objects;
public final class AddressVO {
private final String street;
private final String city;
private final String zipCode;
private final String country;
public AddressVO(String street, String city, String zipCode, String country) {
this.street = street;
this.city = city;
this.zipCode = zipCode;
this.country = country;
}
public String getStreet() {
return street;
}
public String getCity() {
return city;
}
public String getZipCode() {
return zipCode;
}
public String getCountry() {
return country;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
AddressVO address = (AddressVO) o;
return street.equals(address.street)
&& city.equals(address.city)
&& zipCode.equals(address.zipCode)
&& country.equals(address.country);
}
@Override
public int hashCode() {
return Objects.hash(street, city, zipCode, country);
}
}
用户和店铺都可以复用 AddressVO,不会出现「同一个地址两个类各写一遍」的重复。
收个尾:别用 DDD 当优越感
八年经验里我见过两种极端:一种是文件夹很 DDD,业务照旧写在 Service;一种是一听 DDD 就烦,觉得都是空话。
我现在的用法是:统一语言 + 聚合边界 + 实体值对象分清,先让迭代能跑;目录和接口可以慢慢收。
若你正在读小傅哥原文或 bugstack 系资料,把示例跑通再对照自己的业务改,比背定义有用得多。
附录:和前文索引
| 文档 | 说明 |
|---|---|
| DDD_技术小册上理论.md | 小册 01:DDD 是什么、战略/战术等 |
(完)