什么?有人说ddd不好懂!咱们今天就来一场DDD的"脱口秀",把这块硬骨头嚼碎了咽下去。
- 咱们之前做的实惠惠商场开始那么令人崩溃,还记得吗?作为其中的首席架构师(兼背锅侠)。公司早期的系统是一个巨大的、令人窒息的"单体应用",就像一锅所有食材都混在一起的"东北乱炖"。销售说"产品"要打折,仓库说"产品"太重了搬不动,财务说"产品"的成本算不清。一个词,三种理解,天天开会吵架。
一般这种时候都会从天而降一位天才,so领域驱动设计(DDD)咱们这位"老中医"登场了,它开出的药方就是战略设计 和战术设计。
第一部分:战略设计------划定地盘,统一语言
战略设计就像是给你的业务王国画地图,明确哪里是首都,哪里是边疆,大家各自说什么话。
1. 限界上下文 (Bounded Context) ------ "划江而治"
还记得那锅"乱炖"吗?问题的根源就在于没有边界。DDD说:"别吵了,咱们分家!"
- 核心思想: 把一个庞大复杂的系统,按照业务职能划分成一个个独立的、有明确边界的区域。每个区域内部,模型和规则都是自洽的,互不干扰。
- 人话解释: 这就是给你的业务"划地盘"。
- 销售上下文 (Sales Context): 这里是"产品"的天堂。
Product类关心的是价格、促销活动、商品描述。 - 物流上下文 (Shipping Context): 这里是"产品"的健身房。
Product类(可能叫ShippableItem)只关心重量、尺寸、是否易碎。 - 库存上下文 (Inventory Context): 这里是"产品"的仓库。它只关心
剩余数量、存放货架号。
- 销售上下文 (Sales Context): 这里是"产品"的天堂。
看,同样是"产品",在不同地盘里,身份和职责完全不同。这样,销售改个价格,再也不用担心会搞崩仓库的秤。
2. 通用语言 (Ubiquitous Language) ------ "说同一种黑话"
划清了地盘,接下来要解决"鸡同鸭讲"的问题。DDD开出的第二剂药方是:建立通用语言。
- 核心思想: 在同一个限界上下文内,开发人员、产品经理、业务专家必须使用完全相同的词汇来描述业务。这些词汇不仅是口头上的,更要直接体现在你的代码里(类名、方法名、变量名)。
- 人话解释: 告别"这个字段你懂我意思吧"。
- 在销售上下文中,业务方说"提交订单",你的代码里就应该有一个
Order.submit()方法,而不是Order.create()或Order.save()。 - 如果说"取消订单",那就该是
order.cancel(),而不是order.setStatus(OrderStatus.CANCELLED)。
- 在销售上下文中,业务方说"提交订单",你的代码里就应该有一个
这样做的好处是,当业务方说"下单时如果金额超过100块,就免运费",你和你的团队能立刻心领神会,并在 Order 聚合里实现这个逻辑,沟通成本大大降低。
3. 子域划分 (Subdomain) ------ "排兵布阵"
地盘划好了,但不能平均用力。你需要识别出哪些是"命根子",哪些是"螺丝钉"。
| 子域类型 | 角色定位 | 特点与策略 | 举例 (电商场景) |
|---|---|---|---|
| 核心域 (Core Domain) | 王牌部队 | 公司的核心竞争力所在,最复杂、最具差异化。投入最精锐的团队,采用最严谨的DDD设计。 | 交易链路:下单、支付、促销计算。这是电商的灵魂。 |
| 支撑域 (Supporting Domain) | 后勤保障 | 为核心域提供必要的支持,很重要但并非独有。可以采用相对简单的架构,甚至外包开发。 | 用户管理、评论系统。 |
| 通用域 (Generic Domain) | 水电煤 | 任何行业都需要的通用功能,毫无技术壁垒。坚决不自研,买现成的或用开源方案。 | 权限系统 (RBAC)、日志服务、邮件发送。 |
把最强的兵力投入到核心域,确保你的竞争优势牢不可破。
第二部分:战术设计------精兵强将,各司其职
战略定好了,现在该派兵遣将,在具体战场上打仗了。这就是战术设计,也叫"充血模型"的落地。
1. 实体 (Entity) vs 值对象 (Value Object) ------ "有身份证的" vs "没身份的"
- 实体 (Entity): 有唯一标识,关注其生命周期。
- 特点: 即使属性全变了,只要ID不变,它就是同一个东西。比如
User(用户)、Order(订单)。用户改了头像和昵称,还是他本人;订单从"待支付"变成"已完成",也还是那个订单。
- 特点: 即使属性全变了,只要ID不变,它就是同一个东西。比如
- 值对象 (Value Object): 没有唯一标识,通过属性值定义自身,且是不可变的。
- 特点: 一旦创建就不能修改,要改只能换个新的。它通常作为实体的属性存在。比如
Address(地址)、Money(金额)。 - 为什么? 想想看,你把收货地址从"北京"改成"上海",这本质上是创建了一个新地址,替换了旧地址。把它设计成不可变的值对象,可以避免很多并发修改带来的Bug,也更安全。
- 特点: 一旦创建就不能修改,要改只能换个新的。它通常作为实体的属性存在。比如
2. 聚合与聚合根 (Aggregate & Aggregate Root) ------ "家族族长"
这是DDD战术设计的灵魂!
- 聚合 (Aggregate): 一组相关对象的集合,被视为一个数据修改的最小单元。你可以把它想象成一个"家族"。
- 聚合根 (Aggregate Root): 家族的"族长",是外部世界访问这个家族的唯一入口。
- 核心规则:
- 外人不能私闯民宅: 外部代码只能通过聚合根的方法来操作聚合内部的成员,不能直接引用内部的其他实体或值对象。
- 族长维护家规: 聚合根负责保证整个"家族"的数据一致性。所有业务规则都由它来守护。
- 一族之长,唯我独尊: 一个事务只能修改一个聚合,保证聚合内的强一致性。
举个例子:订单 (Order) 就是一个典型的聚合根。
它包含 订单项 (OrderItem)、收货地址 (Address)、订单金额 (Money) 等。
你不能绕过 Order 直接去修改 OrderItem 的数量。你必须调用 order.addItem(product, quantity),由 Order 自己来更新 OrderItem 列表,并重新计算总金额。这样就保证了"订单总金额 = 所有订单项小计之和"这个业务规则永远不会被破坏。
3. 仓储 (Repository) ------ "家族档案管理员"
- 职责: 负责聚合的持久化和检索。它就像一个档案管理员,你告诉他"我要找ID为123的订单家族",他就去数据库里把整个
Order聚合取出来给你。 - 关键点: 仓储的接口定义在领域层,但具体实现(比如是用MySQL还是MongoDB)在基础设施层。这样领域层就和具体的数据库技术解耦了。
4. 领域服务 (Domain Service) ------ "跨部门协调员"
- 职责: 当一个业务逻辑涉及多个聚合,不适合放在任何一个聚合内部时,就交给领域服务。
- 举例: "订单金额计算"可能需要结合
订单、优惠券、会员等级等多个聚合的信息。这个复杂的计算逻辑就可以放在PricingDomainService中。注意,它处理的是核心业务逻辑,不是简单的流程编排。
第三部分:贫血 vs 充血 ------ MVC的痛与DDD的药
传统MVC为什么是"贫血模型"?
在经典的MVC三层架构中,我们通常是这样干的:
- Controller层: 接收请求,参数校验。
- Service层: 业务逻辑的大本营,各种
if-else都在这里。 - DAO/Model层: 纯粹的数据载体,只有
getter/setter,没有任何行为。
这就好比一个公司,所有的决策都由一个庞大的"服务中心"做出,底下的员工(实体对象)全是只会听指令干活的木偶,没有任何主观能动性。这种模型被称为贫血模型 (Anemic Model)。
它的致命弱点是:
- 业务逻辑泄露: 本该属于
Order的"取消"逻辑,却散落在OrderService里,导致代码难以理解和维护。 - 违反封装原则: 对象的内部状态可以被随意修改,业务规则形同虚设。
DDD如何实现"充血模型"?
DDD要把权力下放,让每个对象都"活"起来,为自己的行为和状态负责。这就是充血模型 (Rich Domain Model) 这块设计yyds;其实之前也有过这么设计,现在取百家之长!
对比一下:
❌ 贫血模型 (传统MVC):
// Order.java - 纯粹的"数据袋"
public class Order {
private Long id;
private String status;
// ... 只有一堆 getter/setter
}
// OrderService.java - 臃肿的"上帝服务"
public class OrderService {
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
if ("PAID".equals(order.getStatus())) {
throw new RuntimeException("已支付订单不能取消");
}
order.setStatus("CANCELLED");
orderRepository.save(order);
// ... 可能还有其他通知逻辑
}
}
✅ 充血模型 (DDD):
// Order.java - 有血有肉的"聚合根"
public class Order {
private OrderId id;
private OrderStatus status; // 使用值对象或枚举
// 核心业务行为封装在实体内部
public void cancel() {
if (this.status == OrderStatus.PAID) {
throw new IllegalStateException("已支付订单不能取消");
}
this.status = OrderStatus.CANCELLED;
// 可以触发领域事件,如 OrderCancelledEvent
}
// ... 其他行为,如 addItem(), calculateTotal()
}
// OrderApplicationService.java - 瘦小的"流程编排者"
public class OrderApplicationService {
@Autowired
private OrderRepository orderRepository;
@Transactional
public void cancelOrder(Long orderId) {
// 1. 从仓储获取聚合根
Order order = orderRepository.findById(new OrderId(orderId));
// 2. 调用聚合根的业务方法
order.cancel();
// 3. 仓储保存变更 (或直接提交事务)
}
}
看到了吗?在DDD的充血模型里,cancel 这个动作以及它所附带的业务规则("已支付不能取消"),被完整地封装在了 Order 对象内部。应用服务 (OrderApplicationService) 的角色变得非常清晰:它只负责编排流程(找到订单 -> 让它取消 -> 保存),而不包含任何核心业务逻辑。
这样一来,你的代码就不再是一盘散沙,而是变成了高度内聚、职责分明、易于测试和维护的精锐部队。
第四部分正面教材:DDD 的"充血模型"设计
为了更好的理解DDD,我们选取一个最经典、最容易翻车的场景:电商下单 。我们将使用 java来演示;什么语言都一样,重点在于:把逻辑还给对象,Service 只负责流程编排。
1. 值对象 (Value Objects) ------ 让数据有规矩
首先,我们定义不可变的值对象。这能防止"脏数据"进入系统。
import java.math.BigDecimal;
// 使用 record (Java 14+) 或者 final class 实现不可变性
public record Money(BigDecimal amount) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("金额不能为负数");
}
}
public Money add(Money other) {
return new Money(this.amount.add(other.amount));
}
}
2. 聚合根 (Aggregate Root) ------ 核心主角
这是最关键的部分。注意看 Order 类如何通过方法保护内部状态。
import java.util.ArrayList;
import java.util.List;
public class Order {
private Long id;
private OrderStatus status; // 使用枚举代替 String,更安全
private Money totalAmount;
private final List<OrderItem> items = new ArrayList<>(); // 封装集合
// --- 构造函数私有化,强制通过工厂方法或特定行为创建 ---
protected Order() {} // 供 JPA/Hibernate 反射使用
public Order(Long id) {
this.id = id;
this.status = OrderStatus.PENDING;
this.totalAmount = new Money(BigDecimal.ZERO);
}
// --- 核心业务行为 (充血的核心) ---
/**
* 添加商品
* 外部无法直接操作 items 列表,必须通过这个方法来保证逻辑闭环
*/
public void addItem(Product product, int quantity) {
// 1. 校验库存或数量逻辑
if (quantity <= 0) throw new IllegalArgumentException("数量必须大于0");
// 2. 添加到内部列表
OrderItem item = new OrderItem(product, quantity);
this.items.add(item);
// 3. 【关键点】自动触发重算,保证"总金额=子项之和"这个不变量
// 调用addItem必须调用这一方法,自动的来源;order.addItem(productA, 1);调用这个方法总价会自动变
this.recalculateTotal();
}
/**
* 取消订单
* 业务规则被封装在这里,谁调用都有效
*/
public void cancel() {
// 1. 状态机检查 (Guard Clause)
if (this.status == OrderStatus.PAID) {
throw new IllegalStateException("订单已支付,无法取消");
}
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException("订单已发货,无法取消");
}
// 2. 变更状态
this.status = OrderStatus.CANCELLED;
// 3. 发布领域事件 (可选,用于解耦后续流程,如发送通知)
// DomainEventPublisher.publish(new OrderCancelledEvent(this.id));
}
// --- 内部辅助方法 ---
// 私有方法,只允许内部调用,确保计算逻辑不外泄且必然执行
private void recalculateTotal() {
Money sum = new Money(BigDecimal.ZERO);
for (OrderItem item : items) {
sum = sum.add(item.getSubTotal());
}
this.totalAmount = sum;
}
// --- 只读访问器 ---
// 返回不可修改的列表,防止外部偷偷 add/remove
public List<OrderItem> getItems() {
return new ArrayList<>(items);
}
public OrderStatus getStatus() { return status; }
public Money getTotalAmount() { return totalAmount; }
}
✨ 亮点解析:
- 高内聚 :
cancel()方法里写了死规则。不管是在 Controller、定时任务还是脚本里调用order.cancel(),规则都会被强制执行。 - 数据一致性 :你没法绕过
addItem去修改列表,也没法绕过recalculateTotal去直接设金额。 - 无 Setter :你会发现
totalAmount没有set方法,它是根据业务行为自动算出来的。 - 在任何时刻,只要你拿到
order对象,它的totalAmount永远等于items的总和。这个规则(不变量)被代码结构死死地保护住了,神仙也破坏不了。
3. 仓储 (Repository) ------ 基础设施
接口定义在领域层,实现在基础设施层。
// domain/repository/OrderRepository.java (接口)
public interface OrderRepository {
Order findById(Long id);
void save(Order order);
}
// infrastructure/persistence/JpaOrderRepository.java (实现 - Spring Data JPA)
@Repository
public interface JpaOrderRepository extends JpaRepository<Order, Long>, OrderRepository {
// Spring Data JPA 会自动生成实现
}
4. 应用服务 (Application Service) ------ 指挥官
最后看 Controller 调用的地方。它变得非常干净,只负责:取数据 -> 调行为 -> 存数据。
@Service
@RequiredArgsConstructor // Lombok 注解,自动注入构造器参数
public class OrderAppService {
private final OrderRepository orderRepository;
/**
* 创建订单用例
*/
@Transactional
public Order createOrder(CreateOrderCmd cmd) {
// 1. 实例化聚合根
Order order = new Order(cmd.getUserId());
// 2. 指挥聚合根干活 (把具体的计算逻辑委托给 Order)
for (OrderItemDTO itemDto : cmd.getItems()) {
// 这里假设 Product 也是充血模型,或者直接传 ID 和价格
order.addItem(itemDto.getProduct(), itemDto.getQuantity());
}
// 3. 持久化
return orderRepository.save(order);
}
/**
* 取消订单用例
*/
@Transactional
public void cancelOrder(Long orderId) {
// 1. 获取聚合根
Order order = orderRepository.findById(orderId);
// 2. 执行业务动作 (逻辑全在 Order 类里,Service 根本不需要知道具体规则)
order.cancel();
// 3. 保存 (JPA 会检测脏数据并更新,或者手动 save)
orderRepository.save(order);
}
}
| 特征 | 贫血模型 (Anemic) | 充血模型 (Rich/Rich Domain Model) |
|---|---|---|
| 类的方法 | 全是 getXXX(), setXXX() |
有 addItem(), pay(), cancel() 等动词方法 |
| 属性修改 | 任意位置都可以 obj.setXxx() |
只能通过特定的业务方法修改,字段通常是 private final |
| Service 层 | 包含大量的 if-else 和业务计算 |
只有简单的流程编排(获取->执行->保存) |
| 安全性 | 容易造出"非法对象"(如金额为负的订单) | 构造函数和方法保证了对象一旦创建就是合法的 |
用面向对象的多态和封装特性,去对抗复杂业务的熵增。