DDD 战术设计模式精读:从聚合根到领域事件的代码级实现指南

结合 Java 21 Record/Sealed Class,给出聚合根、值对象、领域事件、仓储接口的完整代码骨架,配合 Spring Boot 集成示例,让 DDD 战术设计真正落地到生产代码。

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 天然适合值对象——编译器自动生成 equalshashCodetoString,且字段默认 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. 这个对象修改后还是"同一个"吗? → 是 = 实体,否 = 值对象

地址是值对象——改了地址就是另一个地址。用户是实体——改了名字还是同一个用户。

四、聚合根:一致性边界的守护者

聚合根是战术设计的核心。它有三个职责:

  1. 保护一致性:确保聚合内的所有状态变更都通过聚合根
  2. 控制访问:外部只能通过聚合根操作内部实体
  3. 发出事件:状态变更后发出领域事件
 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,用仓储接口隔离数据库细节。渐进式改造比推倒重来靠谱得多。

本博客文章采用 CC BY-NC-SA 4.0 许可协议
服务器推荐

腾讯云 · 新用户专属优惠

本博客部署在腾讯云服务器,稳定运行一年多。如果你是新用户或想搭建个人项目,推荐试试腾讯云的优惠活动。

查看优惠详情 →
阅读
上一篇
从设计BOM到制造BOM:PLM与MES系统集成中的BOM转换架构设计
广告

📚 关注公众号,免费获取技术材料

扫码关注公众号,回复「资料」领取:

  • 📘 企业架构设计模板
  • 📗 数据治理实施指南
  • 📙 工业软件技术白皮书
公众号二维码

长按或扫描二维码