第30章:领域驱动设计(DDD)
聊到微服务架构,有个话题绕不开——领域驱动设计。说实话,我见过太多团队把微服务拆得七零八落,最后发现每个服务都在做类似的事情,数据冗余得一塌糊涂。问题出在哪?说白了,就是没搞懂业务边界在哪。
DDD 不是一套新框架,也不是什么银弹。它是一套思维方式。我习惯把它理解为「用代码还原业务现实」的方法论。今天咱们就把它掰开揉碎,看看核心概念到底怎么落地。
实体(Entity):有身份证的对象
实体是什么?简单说,就是有唯一标识、会变化、但身份不变的东西。比如一个用户,他改了名字、换了手机号,他还是他。这个「他」就是实体。
我在项目中遇到过这样的坑:把订单号当实体的唯一标识,结果订单状态变了,整个对象就重建了。这不对。实体的标识应该从出生到消亡都不变。
// 实体示例:用户
public class User {
private final UserId userId; // 值对象作为标识
private String name;
private Email email;
public User(UserId userId, String name, Email email) {
this.userId = userId;
this.name = name;
this.email = email;
}
public void changeEmail(Email newEmail) {
// 业务规则:邮箱不能为空
if (newEmail == null) {
throw new IllegalArgumentException("邮箱不能为空");
}
this.email = newEmail;
}
// equals 和 hashCode 基于 userId
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return userId.equals(user.userId);
}
@Override
public int hashCode() {
return userId.hashCode();
}
}
看到了吗?equals 和 hashCode 只依赖 userId。这才是实体的正确姿势。
值对象(Value Object):描述事物,不关心是谁
值对象没有身份。两个值对象只要属性相同,就是同一个东西。比如地址、金额、颜色。你想想看,一个地址「北京市朝阳区xxx」,换了个房子,地址变了,但地址本身没有「身份」这个概念。
核心区别:实体看「是谁」,值对象看「是什么」。
// 值对象示例:金额
public class Money {
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("币种不一致");
}
return new Money(this.amount.add(other.amount), this.currency);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return amount.equals(money.amount) && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
值对象是不可变的。一旦创建,就不能改。要改?新建一个。这能避免很多并发问题。我曾经在电商系统里把订单金额做成可变字段,结果并发扣款时数据全乱了。嗯,从那以后我再也不敢让值对象可变。
聚合(Aggregate):一致性边界
聚合是一组相关对象的集合,对外暴露一个根实体。所有外部访问都必须通过聚合根。为什么?为了保证数据一致性。
举个例子:订单和订单项。你不能直接修改订单项的数量,必须通过订单这个聚合根来操作。否则可能出现订单总金额和明细对不上的情况。
// 聚合根:订单
public class Order {
private final OrderId orderId;
private List<OrderItem> items;
private OrderStatus status;
private Money totalAmount;
public void addItem(Product product, int quantity) {
// 业务规则:已支付的订单不能加商品
if (status == OrderStatus.PAID) {
throw new IllegalStateException("已支付订单不能修改");
}
OrderItem item = new OrderItem(product, quantity);
items.add(item);
recalculateTotal();
}
public void pay() {
// 业务规则:空订单不能支付
if (items.isEmpty()) {
throw new IllegalStateException("空订单不能支付");
}
this.status = OrderStatus.PAID;
// 发布领域事件
DomainEventPublisher.publish(new OrderPaidEvent(this.orderId));
}
private void recalculateTotal() {
this.totalAmount = items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.ZERO, Money::add);
}
}
我的习惯:一个聚合不要太大。我见过一个聚合包含几十个实体,结果每次加载都慢得要命。聚合的粒度应该跟事务边界一致。如果一个操作需要锁住太多数据,说明聚合拆得不够细。
领域事件(Domain Event):发生了什么?
领域事件记录「已经发生的事」。比如「订单已支付」「用户已注册」。它们是不可变的,通常用过去时命名。
为什么要用领域事件?解耦。订单支付后,需要发短信、更新库存、通知物流。如果这些逻辑都写在订单服务里,耦合度太高。用事件驱动,订单只管发布事件,其他服务自己订阅。
// 领域事件
public class OrderPaidEvent {
private final OrderId orderId;
private final LocalDateTime occurredOn;
public OrderPaidEvent(OrderId orderId) {
this.orderId = orderId;
this.occurredOn = LocalDateTime.now();
}
// getters...
}
// 事件处理器
public class InventoryEventHandler {
@EventListener
public void handle(OrderPaidEvent event) {
// 扣减库存
inventoryService.deductStock(event.getOrderId());
}
}
注意:领域事件不要滥用。我见过有人把每个字段变化都发布一个事件,结果事件风暴比业务逻辑还复杂。只在「业务上有意义」的时刻发布事件。
限界上下文(Bounded Context):划清地盘
这是 DDD 里最容易被忽略、但最重要的概念。限界上下文就是「一个明确的边界」,在这个边界内,术语有统一的含义。
举个例子:「用户」在电商系统里,下单上下文里叫「买家」,物流上下文里叫「收件人」,客服上下文里叫「客户」。同一个人的不同角色,在不同上下文里有不同的属性和行为。
| 上下文 | 实体名称 | 关注属性 |
|---|---|---|
| 订单上下文 | 买家 | 收货地址、积分 |
| 物流上下文 | 收件人 | 姓名、电话、地址 |
| 客服上下文 | 客户 | 历史投诉、等级 |
我在项目中遇到过这样的问题:两个团队用同一个「用户」对象,结果一个加了字段,另一个就崩了。后来我们划清了限界上下文,各自维护自己的模型,通过防腐层(Anti-Corruption Layer)做转换,问题就解决了。
DDD 与微服务的关系
很多人问:DDD 和微服务到底什么关系?我的回答是:DDD 是微服务拆分的「手术刀」。
没有 DDD 的微服务,很容易拆成「技术分层」——一个服务管数据库,一个服务管业务逻辑,一个服务管展示。这不是微服务,这是分布式单体。
正确的做法是:每个微服务对应一个限界上下文。订单上下文一个服务,支付上下文一个服务,库存上下文一个服务。每个服务有自己的数据库、自己的领域模型、自己的部署单元。
核心原则:微服务的边界 = 限界上下文的边界。一个上下文一个服务,不要跨上下文共享数据库。
你想想看,如果订单服务和支付服务共享一个数据库,那它们还能独立部署吗?改个表结构就得两边一起改,这不又回到单体了?
下面这张图展示了 DDD 与微服务的映射关系:
看到这张图就明白了:DDD 帮我们找到业务边界,微服务帮我们实现技术边界。两者是天然的搭档。
实践中的避坑指南
最后分享几个我踩过的坑:
- 不要一开始就追求完美建模。 我见过有人花三个月画领域模型,结果业务变了,模型全废。DDD 是迭代的,先跑起来再优化。
- 聚合不要太大。 一个聚合包含几十个实体,加载慢、并发冲突多。我习惯一个聚合不超过 5 个实体。
- 领域事件不要滥用。 只在「业务有明确含义」的时刻发布。比如「订单已支付」有意义,「用户点击了按钮」没意义。
- 限界上下文之间用防腐层。 不要直接引用另一个上下文的实体。用 DTO 或接口做隔离。
我的建议:如果你刚开始接触 DDD,先从一个核心业务场景入手。比如电商的订单流程,把实体、值对象、聚合、领域事件都走一遍。别想着全公司推广,先让一个团队跑通,再慢慢扩展。
DDD 不是银弹,但它能帮你把复杂业务理清楚。尤其是做微服务架构,没有 DDD 的指引,很容易拆成「技术微服务」——每个服务管一层,而不是管一个业务。记住:微服务的边界,就是业务的边界。
公众号:蓝海资料掘金营,微信deep3321