DDD(领域驱动设计)讲了二十多年,真正落地到代码里的团队不多。问题不是概念太难,是大部分教程停留在战略设计层面——画上下文映射、讨论限界边界,一到写代码就变成了贫血模型+Service 层堆业务逻辑。
本文聚焦战术设计,用 Java 21 的 Record、Sealed Class、Pattern Matching 等新特性,给出聚合根、值对象、领域事件、仓储接口的完整代码骨架。目标是:读完就能在 Spring Boot 项目里用起来,不用等"团队 DDD 培训"。
一、战术设计的四个核心构件
DDD 战术设计有四个基本构建块,它们的关系决定了领域模型的表达能力:
| 构件 |
职责 |
可变性 |
标识 |
典型例子 |
| 聚合根(Aggregate Root) |
一致性边界的守护者 |
可变 |
有唯一ID |
订单、用户、商品 |
| 实体(Entity) |
聚合内部的业务对象 |
可变 |
有ID,但只在聚合内有意义 |
订单项、地址 |
| 值对象(Value Object) |
不可变的属性组合 |
不可变 |
无ID,按值相等 |
金额、坐标、日期范围 |
| 领域事件(Domain Event) |
聚合状态变更的事实记录 |
不可变 |
无 |
订单已创建、支付已完成 |
这四个构件不是平级的——聚合根是入口,实体和值对象是它的内部结构,领域事件是它对外发出的信号。
二、值对象:用 Record 实现不可变语义
值对象是最容易被忽视、也是最应该优先使用的构件。它的特点是:
- 没有ID:两个金额相同的 Money 对象是同一个值
- 不可变:创建后不能修改,只能创建新的
- 自验证:创建时就保证合法性,不存在"半成品"状态
Java 21 的 Record 天然适合值对象——编译器自动生成 equals、hashCode、toString,且字段默认 final:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
public record Money(BigDecimal amount, Currency currency) {
// 紧凑构造函数:自验证
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("金额不能为负: " + amount);
}
Objects.requireNonNull(currency, "币种不能为空");
}
// 业务方法:值对象可以包含领域逻辑
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("币种不一致: " + this.currency + " vs " + other.currency);
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
// 工厂方法:从字符串解析
public static Money of(String value) {
// 格式: "100.00 CNY"
String[] parts = value.split(" ");
return new Money(new BigDecimal(parts[0]), Currency.getInstance(parts[1]));
}
}
|
值对象的设计原则
1. 构造函数即验证
不要在 setter 里做校验(值对象没有 setter)。所有合法性检查放在构造函数里,保证创建出来的对象一定合法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public record DateRange(LocalDate start, LocalDate end) {
public DateRange {
if (start.isAfter(end)) {
throw new IllegalArgumentException("开始日期不能晚于结束日期");
}
}
public long days() {
return ChronoUnit.DAYS.between(start, end);
}
public boolean contains(LocalDate date) {
return !date.isBefore(start) && !date.isAfter(end);
}
}
|
2. 值对象可以组合
复杂的属性用多个值对象组合,而不是拍平成一个大对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
public record Address(
String province,
String city,
String district,
String street
) {
public String fullAddress() {
return province + city + district + street;
}
}
public record GeoPoint(double latitude, double longitude) {
public GeoPoint {
if (latitude < -90 || latitude > 90) {
throw new IllegalArgumentException("纬度超出范围: " + latitude);
}
if (longitude < -180 || longitude > 180) {
throw new IllegalArgumentException("经度超出范围: " + longitude);
}
}
}
// 组合使用
public record StoreLocation(Address address, GeoPoint coordinates) { }
|
三、实体:聚合内的可变对象
实体和值对象的区别:实体有ID,值对象没有。
实体在聚合内部有独立的生命周期,但它不能脱离聚合根独立存在。典型例子是"订单项"——它有行号(ID),可以被修改(数量、价格),但不能脱离订单单独操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
public class OrderItem {
private final int lineNo; // 实体ID(聚合内唯一)
private final String productName;
private int quantity; // 可变
private Money unitPrice; // 值对象
private Money subtotal; // 派生值
public OrderItem(int lineNo, String productName, int quantity, Money unitPrice) {
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须大于0");
}
this.lineNo = lineNo;
this.productName = productName;
this.quantity = quantity;
this.unitPrice = unitPrice;
this.subtotal = unitPrice.multiply(quantity);
}
// 业务方法:修改数量
public void changeQuantity(int newQuantity) {
if (newQuantity <= 0) {
throw new IllegalArgumentException("数量必须大于0");
}
this.quantity = newQuantity;
this.subtotal = this.unitPrice.multiply(newQuantity);
}
// Getters(省略)
}
|
实体 vs 值对象:怎么选?
问自己两个问题:
- 这个对象需要被单独引用吗? → 是 = 实体,否 = 值对象
- 这个对象修改后还是"同一个"吗? → 是 = 实体,否 = 值对象
地址是值对象——改了地址就是另一个地址。用户是实体——改了名字还是同一个用户。
四、聚合根:一致性边界的守护者
聚合根是战术设计的核心。它有三个职责:
- 保护一致性:确保聚合内的所有状态变更都通过聚合根
- 控制访问:外部只能通过聚合根操作内部实体
- 发出事件:状态变更后发出领域事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
|
public class Order {
private final OrderId id; // 聚合根ID
private final CustomerId customerId;
private final List<OrderItem> items; // 内部实体
private OrderStatus status; // 状态
private Money totalAmount; // 派生值
private final List<DomainEvent> events; // 待发出的事件
// 创建订单(工厂方法)
public static Order create(CustomerId customerId, List<OrderItem> items) {
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("订单至少需要一个商品");
}
Order order = new Order(
OrderId.generate(),
customerId,
new ArrayList<>(items),
OrderStatus.CREATED,
calculateTotal(items),
new ArrayList<>()
);
// 注册创建事件
order.registerEvent(new OrderCreatedEvent(
order.id, order.customerId, order.totalAmount, Instant.now()
));
return order;
}
// 业务方法:添加商品
public void addItem(String productName, int quantity, Money unitPrice) {
ensureCanModify();
int nextLineNo = items.size() + 1;
items.add(new OrderItem(nextLineNo, productName, quantity, unitPrice));
recalculateTotal();
registerEvent(new OrderItemAddedEvent(id, nextLineNo, productName, quantity));
}
// 业务方法:确认订单
public void confirm() {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("只有已创建的订单才能确认");
}
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(id, Instant.now()));
}
// 业务方法:取消订单
public void cancel(String reason) {
ensureCanCancel();
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(id, reason, Instant.now()));
}
// 收集待发出的事件
public List<DomainEvent> collectEvents() {
List<DomainEvent> result = new ArrayList<>(events);
events.clear();
return result;
}
// 私有方法:状态检查
private void ensureCanModify() {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("只有已创建的订单才能修改");
}
}
private void ensureCanCancel() {
if (status == OrderStatus.CANCELLED) {
throw new IllegalStateException("订单已经取消");
}
if (status == OrderStatus.COMPLETED) {
throw new IllegalStateException("已完成的订单不能取消");
}
}
private void recalculateTotal() {
this.totalAmount = items.stream()
.map(item -> item.getSubtotal())
.reduce(Money.ZERO, Money::add);
}
private void registerEvent(DomainEvent event) {
events.add(event);
}
}
|
聚合根的设计原则
1. 聚合要小
聚合根保护的一致性边界越大,并发冲突越多,性能越差。经验法则:
- 一个聚合根管理的实体数量不超过 5-10 个
- 能通过ID引用的对象不要放在聚合内(用关联代替包含)
- 跨聚合的操作通过最终一致性,不要用强一致性
2. 只通过聚合根修改内部状态
不要让外部直接拿到 OrderItem 然后调用 changeQuantity。所有修改都通过 Order 的方法:
1
2
3
4
5
|
// ❌ 错误:直接操作内部实体
order.getItems().get(0).changeQuantity(5);
// ✅ 正确:通过聚合根
order.changeItemQuantity(1, 5);
|
3. 聚合根方法返回 void 或值对象
不要返回内部实体的引用。如果需要查询,返回值对象的副本或投影。
五、领域事件:用 Sealed Class 建模
领域事件记录"发生了什么"。Java 21 的 Sealed Class + Pattern Matching 让事件建模变得类型安全:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// 基类:sealed 限制子类
public sealed interface OrderEvent extends DomainEvent {
OrderId orderId();
Instant occurredAt();
}
// 具体事件:record 实现不可变性
public record OrderCreatedEvent(
OrderId orderId,
CustomerId customerId,
Money totalAmount,
Instant occurredAt
) implements OrderEvent { }
public record OrderConfirmedEvent(
OrderId orderId,
Instant occurredAt
) implements OrderEvent { }
public record OrderCancelledEvent(
OrderId orderId,
String reason,
Instant occurredAt
) implements OrderEvent { }
public record OrderItemAddedEvent(
OrderId orderId,
int lineNo,
String productName,
int quantity
) implements OrderEvent { }
|
事件处理:Pattern Matching
Java 21 的 switch pattern matching 让事件处理变得优雅:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
public class OrderEventHandler {
public void handle(OrderEvent event) {
switch (event) {
case OrderCreatedEvent e -> onCreated(e);
case OrderConfirmedEvent e -> onConfirmed(e);
case OrderCancelledEvent e -> onCancelled(e);
case OrderItemAddedEvent e -> onItemAdded(e);
}
// 不需要 default 分支——sealed 保证穷举
}
private void onCreated(OrderCreatedEvent event) {
// 发送创建通知
notificationService.sendOrderCreatedEmail(event.customerId(), event.orderId());
// 初始化库存预占
inventoryService.reserveForOrder(event.orderId(), event.totalAmount());
}
private void onConfirmed(OrderConfirmedEvent event) {
// 触发支付流程
paymentService.initiatePayment(event.orderId());
}
private void onCancelled(OrderCancelledEvent event) {
// 释放库存
inventoryService.releaseForOrder(event.orderId());
// 记录取消原因
auditService.log(event.orderId(), "Cancelled: " + event.reason());
}
private void onItemAdded(OrderItemAddedEvent event) {
// 检查库存
inventoryService.checkAvailability(event.productName(), event.quantity());
}
}
|
事件溯源 vs 事件通知
两种用法,不要混淆:
| 用法 |
目的 |
存储 |
典型场景 |
| 事件溯源 |
用事件序列重建聚合状态 |
必须持久化所有事件 |
金融、审计 |
| 事件通知 |
触发其他聚合/系统的反应 |
事件发出后可丢弃 |
通知、库存、支付 |
大部分业务系统用事件通知就够了。事件溯源增加了复杂度,只在强审计需求时使用。
六、仓储接口:隔离持久化
仓储(Repository)的职责:让聚合根感觉像内存中的集合。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
public interface OrderRepository {
// 保存聚合根(包含所有内部实体)
void save(Order order);
// 按ID加载整个聚合
Optional<Order> findById(OrderId id);
// 查询(返回投影,不返回聚合根)
List<OrderSummary> findByCustomer(CustomerId customerId);
// 查询(返回投影)
List<OrderSummary> findByStatus(OrderStatus status, int limit);
}
// 查询投影:不是聚合根,是只读视图
public record OrderSummary(
OrderId orderId,
CustomerId customerId,
OrderStatus status,
Money totalAmount,
int itemCount,
Instant createdAt
) { }
|
仓储的设计原则
1. 每个聚合根一个仓储
不要给 OrderItem 单独建仓储。OrderItem 只能通过 Order 访问。
2. 查询返回投影,不返回聚合根
列表查询不需要加载整个聚合。用 DTO/Record 返回投影,性能更好:
1
2
3
4
5
|
// ❌ 错误:列表查询返回聚合根
List<Order> findByCustomer(CustomerId id);
// ✅ 正确:列表查询返回投影
List<OrderSummary> findByCustomer(CustomerId id);
|
3. 仓储接口在领域层,实现在基础设施层
接口定义在领域包里,JPA/MyBatis 实现在基础设施包里。依赖倒置,领域层不依赖数据库。
七、Spring Boot 集成:事件发布
把领域事件接入 Spring 的事件机制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderApplicationService(
OrderRepository orderRepository,
ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public OrderId createOrder(CustomerId customerId, List<CreateOrderItemCommand> items) {
// 1. 转换为领域对象
List<OrderItem> orderItems = items.stream()
.map(item -> new OrderItem(
item.lineNo(),
item.productName(),
item.quantity(),
Money.of(item.unitPrice())
))
.toList();
// 2. 创建聚合根
Order order = Order.create(customerId, orderItems);
// 3. 保存
orderRepository.save(order);
// 4. 发布领域事件
order.collectEvents().forEach(eventPublisher::publishEvent);
return order.getId();
}
@Transactional
public void confirmOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.confirm();
orderRepository.save(order);
order.collectEvents().forEach(eventPublisher::publishEvent);
}
}
|
事件监听
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
@Component
public class OrderEventListener {
private final NotificationService notificationService;
private final InventoryService inventoryService;
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
notificationService.sendOrderCreatedEmail(event.customerId(), event.orderId());
inventoryService.reserveForOrder(event.orderId(), event.totalAmount());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderConfirmed(OrderConfirmedEvent event) {
// 事务提交后才执行,避免事务回滚但通知已发出
paymentService.initiatePayment(event.orderId());
}
}
|
两种事件监听的区别
| 注解 |
事务关系 |
适用场景 |
@EventListener |
同一事务 |
同步操作、必须在同一事务内完成 |
@TransactionalEventListener |
事务提交后 |
异步操作、发通知、调外部API |
八、JPA 实体映射:聚合根到数据库
聚合根和 JPA Entity 是两个概念,但可以用 JPA 实现持久化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
@Id
@Column(name = "order_id")
private String id;
@Column(name = "customer_id", nullable = false)
private String customerId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private OrderStatus status;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "amount", column = @Column(name = "total_amount")),
@AttributeOverride(name = "currency", column = @Column(name = "total_currency"))
})
private MoneyJpaEmbeddable totalAmount;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItemJpaEntity> items = new ArrayList<>();
@Column(name = "created_at")
private Instant createdAt;
// 领域对象 → JPA 实体
public static OrderJpaEntity fromDomain(Order order) {
OrderJpaEntity entity = new OrderJpaEntity();
entity.id = order.getId().value();
entity.customerId = order.getCustomerId().value();
entity.status = order.getStatus();
entity.totalAmount = MoneyJpaEmbeddable.fromDomain(order.getTotalAmount());
entity.createdAt = order.getCreatedAt();
entity.items = order.getItems().stream()
.map(item -> OrderItemJpaEntity.fromDomain(item, entity))
.toList();
return entity;
}
// JPA 实体 → 领域对象
public Order toDomain() {
List<OrderItem> domainItems = items.stream()
.map(OrderItemJpaEntity::toDomain)
.toList();
return Order.reconstitute(
OrderId.of(id),
CustomerId.of(customerId),
domainItems,
status,
totalAmount.toDomain(),
createdAt
);
}
}
|
仓储实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
@Override
public void save(Order order) {
OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
jpaRepository.save(entity);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value())
.map(OrderJpaEntity::toDomain);
}
@Override
public List<OrderSummary> findByCustomer(CustomerId customerId) {
return jpaRepository.findByCustomerId(customerId.value())
.stream()
.map(e -> new OrderSummary(
OrderId.of(e.getId()),
CustomerId.of(e.getCustomerId()),
e.getStatus(),
e.getTotalAmount().toDomain(),
e.getItems().size(),
e.getCreatedAt()
))
.toList();
}
}
|
九、聚合间引用:用 ID 而非对象
跨聚合不要直接持有对象引用,用ID:
1
2
3
4
5
6
7
8
9
|
// ❌ 错误:Order 直接持有 Customer 对象
public class Order {
private Customer customer; // 加载 Order 时会加载整个 Customer
}
// ✅ 正确:Order 只持有 CustomerId
public class Order {
private CustomerId customerId; // 需要 Customer 信息时单独查询
}
|
什么时候需要 Customer 信息?
两种策略:
1. 延迟加载(适合偶尔需要)
1
2
3
4
5
6
7
8
|
public class Order {
private CustomerId customerId;
// 需要时通过仓储查询
public CustomerSnapshot getCustomerSnapshot(CustomerRepository repo) {
return repo.findSnapshotById(customerId);
}
}
|
2. 冗余存储(适合频繁需要)
1
2
3
4
5
6
|
public class Order {
private CustomerId customerId;
private String customerName; // 创建时从 Customer 复制
// 不需要额外查询,但数据可能过期
}
|
选哪个取决于查询频率和数据一致性要求。大部分场景用ID引用+偶尔查询就够了。
十、战术设计的常见陷阱
1. 聚合根太大
一个聚合根管了十几个实体,每次修改都要加载整个聚合。解决:拆分成多个小聚合,用最终一致性。
2. 值对象可变
值对象设计成可变类(有setter),导致共享引用时出现幽灵bug。解决:用 Record,编译器强制不可变。
3. 领域事件在事务提交前处理
用 @EventListener 处理事件,事务回滚但事件已处理。解决:用 @TransactionalEventListener(phase = AFTER_COMMIT)。
4. 仓储返回聚合根列表
列表查询返回 List<Order>,每次都加载完整聚合。解决:返回投影 List<OrderSummary>。
5. 聚合间直接引用
Order 持有 Customer 对象,导致级联加载和级联删除。解决:用 CustomerId 引用。
小结
DDD 战术设计的核心是边界——聚合根划定了状态变更的边界,值对象划定了不可变的边界,领域事件划定了系统间的边界。
Java 21 的 Record 让值对象变得简洁安全,Sealed Class 让领域事件变得类型完备,Pattern Matching 让事件处理变得优雅。这些语言特性不是锦上添花,是让 DDD 从"理论正确"变成"代码可行"的关键。
战术设计落地不需要推翻现有架构。从一个小聚合开始,用值对象替换散落的参数,用领域事件替代 Service 层的 if-else,用仓储接口隔离数据库细节。渐进式改造比推倒重来靠谱得多。