从类爆炸到协作——DDD战略设计登场

战术设计解决的是"一个团队怎么写代码"的问题。战略设计解决的是"多个团队怎么一起写代码"的问题。

前面两篇文章,我们走了两条路。

把三层架构那个2000行的Service拆开,用四层架构+充血模型,让业务规则各归其位。发现类爆炸了------一个功能从1个类变成10个类,项目从50个类膨胀到152个。然后用桥接模式、视图模式、充血模型,把不必要的类砍掉了一半。

两篇文章解决的都是 "一个团队、一个项目" 内部的问题。

现在假设一个更大的场景:你的系统已经大到需要多个团队并行开发了。

订单团队、商品团队、库存团队、支付团队、用户团队......每个团队都有自己的代码库、自己的发布节奏、自己的领域专家。

这时候,前面两篇文章里的所有技巧------充血模型、桥接模式、视图模式------都还在用,但已经不够了。

因为问题变了。

从前的问题是:"一个Service里逻辑太乱怎么办?"

现在的问题是:"订单上下文里的'商品'和商品上下文里的'商品',是同一个东西吗?"

这不是代码层面的问题,这是认知层面的问题。

DDD把前一种问题交给战术设计 (前两篇讲的就是这个),把后一种问题交给战略设计。

读完这篇文章,你将获得:

✅ 理解限界上下文 是什么------为什么"商品"在订单里和库存里不是同一个概念

✅ 学会用事件风暴 划分限界上下文------把大系统切成多个小模块

✅ 掌握上下文映射 的三种核心模式------多个上下文之间怎么协作

✅ 看清战略设计 和战术设计的分工------什么时候该用哪个

一、一个让你头疼的真实场景

你所在的电商系统已经运行了三年。现在有五个团队并行开发:

  • 订单团队:负责下单、订单状态管理
  • 商品团队:负责商品上架、分类、详情
  • 库存团队:负责库存扣减、补货
  • 支付团队:负责支付流程、退款
  • 用户团队:负责注册、登录、积分

某天,订单团队要给"订单详情页"加一个字段:productCategory(商品分类)。

订单团队的开发说:"很简单,我在OrderItem里加一个category字段就行了。"

商品团队的开发看到了,说:"等等!'分类'在我们这边有三级结构,你那个category是个字符串,跟我们的Category实体根本不是一回事。你不能这么用。"

订单团队说:"我只是想展示一下分类名称而已,不需要你们那三级结构。"

商品团队说:"那你至少应该调用我们的API来获取分类名称,而不是自己存一份。"

两个团队吵起来了。

谁对?

都没错。

订单团队想要的是"展示用"的分类名称------一个字符串就够了。商品团队维护的是"业务用"的分类体系------有层级、有属性、有规则。

它们说的"分类",在不同的上下文里有不同的含义。

这个"上下文",就是DDD战略设计的核心概念------限界上下文(Bounded Context)。

限界上下文是语义和语境上的边界。边界内的每个组件都有特定的含义,边界外的同一词汇可能有完全不同的意思。

二、限界上下文:给"同一个词"画一个边界

2.1 什么是限界上下文?

回到刚才的例子。

在订单上下文 里,"商品"是什么?是一个OrderItem------有商品ID、商品名称、单价、数量、小计金额。它是一个交易凭证,记录的是"下单那一刻"的商品快照。订单上下文不关心商品的库存、不关心商品的分类层级、不关心商品的上下架状态------它只关心"这个商品当时卖多少钱、买了多少个"。

在商品上下文 里,"商品"是什么?是一个Product------有SKU、有分类、有详情描述、有规格参数、有上下架状态。它是一个信息实体,记录的是"商品本身是什么"。商品上下文不关心某个商品被谁买过、买了几次------它只关心"这个商品现在是什么状态、有什么属性"。

在库存上下文 里,"商品"又是什么?是一个InventoryItem------有商品ID、有可用库存、有锁定库存、有在途库存。它是一个数量凭证,记录的是"这个商品还有多少能卖"。库存上下文不关心商品的分类、不关心商品的价格------它只关心"数量"。

同一个词"商品",在三个上下文里有三种不同的含义、三种不同的字段、三种不同的行为。

如果你强行把它们塞进同一个Product类里,结果就是:

java 复制代码
// 一个试图满足所有人的"上帝类"
public class Product {
    // 商品上下文需要的
    private String name;
    private String description;
    private Category category;
    private List<Spec> specs;
    
    // 订单上下文需要的
    private BigDecimal priceAtOrder;  // 订单里的价格是快照!
    private Integer quantityAtOrder;  // 订单里的数量是快照!
    
    // 库存上下文需要的
    private Integer availableStock;
    private Integer lockedStock;
    private Integer inboundStock;
    
    // 还有更多......
}

这个类会变得越来越臃肿,越来越难以理解。改一个字段,不知道会影响哪个上下文。这就是大泥球 (Big Ball of Mud)------所有东西混在一起,谁也说不清边界在哪里。

限界上下文就是用来解决这个问题的。 它给每个上下文画了一个清晰的边界,边界内使用一套独立的模型。订单上下文有自己的OrderItem,商品上下文有自己的Product,库存上下文有自己的InventoryItem。它们可以共享同一个数据库物理表(通过不同的映射),也可以使用完全独立的数据库------但代码层面,它们是彻底分开的。

2.2 怎么划分限界上下文?

限界上下文不是拍脑袋定的。DDD社区有一套成熟的方法------事件风暴 (Event Storming)。

事件风暴怎么做?

简单来说,就是拉上领域专家、产品经理、开发人员,围在一面巨大的白板前,用便利贴把业务流程中的事件(Event)全部贴出来。

比如一个"用户下单"的流程:

  1. 用户提交订单 → 订单已创建
  2. 系统扣减库存 → 库存已扣减
  3. 系统发起支付 → 支付已发起
  4. 用户完成支付 → 支付已完成
  5. 系统更新订单状态 → 订单已支付
  6. 系统增加用户积分 → 积分已增加

每一个"已 "字结尾的句子,就是一个领域事件。

然后,把这些事件按照业务阶段分组。比如:

  • "订单已创建""订单已支付""订单已发货""订单已完成" → 订单上下文
  • "库存已扣减""库存已恢复""库存已补货" → 库存上下文
  • "支付已发起""支付已完成""退款已完成" → 支付上下文

分完组之后,再给每个组起一个名字------这就是限界上下文 。

划分的核心理念 :限界上下文应该与子域(Subdomain)一一对应。子域是业务层面的划分(订单域、库存域、支付域),限界上下文是解决方案层面的划分(订单上下文、库存上下文、支付上下文)。

简单说:子域回答"业务上分几块",限界上下文回答"代码上怎么切"。

2.3 限界上下文的三种类型

不是所有的限界上下文都同等重要。DDD把它们分成三类:

类型 含义 例子 投入策略
核心域 公司最核心的竞争力,最需要投入精力的部分 电商的订单履约、推荐算法 最高优先级,最精细建模
支撑子域 为核心域服务的定制开发部分 内部的权限管理、配置中心 适度投入,够用就好
通用子域 行业通用的、可以买现成的部分 支付网关对接、短信发送 最小投入,能用就行

认清这三种类型,你就知道精力该花在哪里。核心域值得你用充血模型、用事件溯源、用最精细的设计。通用子域,调个API就行了,别在上面过度设计。

三、上下文映射:限界上下文之间怎么"说话"?

划分完限界上下文之后,下一个问题来了:它们之间怎么协作?

订单上下文需要知道库存够不够,库存上下文需要知道订单有没有支付,支付上下文需要知道订单金额是多少......它们虽然被切开了,但终究还是一个系统,免不了交互 。

描述这些交互关系的方式,叫做上下文映射 (Context Mapping)。

上下文映射就是一张"系统间的关系地图"------告诉你谁依赖谁、怎么依赖、依赖到什么程度。

3.1 三种最常用的映射模式

DDD定义了九种上下文映射模式。但日常开发中,90%的场景只用得到三种 。

模式一:防腐层(ACL------Anti-Corruption Layer)

场景:你的上下文要依赖一个"外部"上下文------可能是遗留系统,可能是第三方服务,可能是另一个团队维护的系统。这个外部系统的模型很"脏",跟你自己的纯净模型不匹配。

做法 :在你们两个上下文之间,建一个翻译层。外部系统的"脏模型"进来,被翻译成你内部的"干净模型"。

java 复制代码
// 文件: infrastructure/acl/ExternalProductTranslator.java
// 这是防腐层------把外部系统的模型翻译成内部模型

@Component
public class ExternalProductTranslator {
    
    // 外部系统的商品模型(可能是XML、可能是旧版的DTO)
    public InternalProduct toInternal(LegacyProduct external) {
        // 翻译逻辑:把外部乱七八糟的字段映射到内部干净的模型
        return new InternalProduct(
            external.getProductId(),
            external.getProductName(),  // 外部叫ProductName,内部叫name
            parseCategory(external.getCategoryCode())  // 外部的分类代码转成内部的对象
        );
    }
}

防腐层的价值 :当外部系统发生变化时(比如遗留系统升级、第三方API改版),你只需要改防腐层,内部的领域模型完全不受影响。

防腐层就像两个国家之间的翻译官------对方说什么语言不重要,翻译官会帮你转成你听得懂的话。

模式二:开放主机服务(OHS------Open Host Service)

场景:你的上下文是"核心域",有很多下游上下文需要调用你。你不想让每个下游都用不同的方式调用------那样你会疯掉。

做法 :你定义一套公开的、有文档的、稳定的API ,让所有下游统一通过这套API来调用你。

java 复制代码
// 文件: interfaces/openapi/ProductOpenApi.java
// 这是开放主机服务------所有下游统一通过这里调用商品上下文

@RestController
@RequestMapping("/open-api/v1/products")
public class ProductOpenApi {
    
    @Autowired private ProductAppService productService;
    
    // 所有下游统一用这个接口查商品信息
    @GetMapping("/{productId}")
    public ProductOpenDto getProduct(@PathVariable Long productId) {
        return productService.getProduct(productId);
    }
}

开放主机服务的价值:你只需要维护一套API,所有下游都用同样的方式调用。新增下游不需要你额外适配。

开放主机服务就像一个机场------所有航班都用同样的跑道起降,而不是每个航空公司修一条自己的跑道。

模式三:防腐层 + 开放主机服务(最经典的组合)

在实际项目中,这两个模式通常是成对出现的。

  • 上游上下文 提供开放主机服务(OHS)------定义一套稳定的公开API
  • 下游上下文 实现防腐层(ACL)------调用上游API,并把结果翻译成自己的模型
java 复制代码
// 上游:订单上下文提供开放主机服务
// 文件: order/interfaces/openapi/OrderOpenApi.java
@RestController
@RequestMapping("/open-api/v1/orders")
public class OrderOpenApi {
    @GetMapping("/{orderId}/status")
    public OrderStatusDto getOrderStatus(@PathVariable Long orderId) {
        // 返回订单状态
    }
}

// 下游:库存上下文实现防腐层
// 文件: inventory/infrastructure/acl/OrderStatusTranslator.java
@Component
public class OrderStatusTranslator {
    @Autowired private OrderOpenApiClient client;  // 调用上游的OHS
    
    public InventoryOrderStatus toInternal(OrderStatusDto external) {
        // 把订单上下文的"PAID"翻译成库存上下文能理解的"已支付可发货"
        return InventoryOrderStatus.fromExternal(external);
    }
}

这样做的效果:

  • 上游变了 → 只改OHS的接口定义,下游的ACL跟着适配
  • 下游变了 → 只改自己的ACL翻译逻辑,上游完全不知道
  • 双方各自独立演进,互不干扰

3.2 一张表看懂三种模式

模式 谁用 解决什么问题 一句话解释
防腐层(ACL) 下游上下文 防止上游的"脏模型"污染自己的纯净模型 翻译官
开放主机服务(OHS) 上游上下文 让所有下游用统一的方式调用自己 公共机场
ACL + OHS 上下游配合 上游稳定输出,下游安全接入 标准接口 + 本地翻译

四、战术设计 vs 战略设计:两张地图

到这里,我们可以把两篇文章的内容串起来了。

战术设计 (前两篇)解决的是"一个限界上下文内部怎么写代码":

战术设计工具 解决什么问题
四层架构 把Service拆成应用层和领域层
充血模型 把逻辑从Service下沉到实体
值对象 封装细粒度的业务规则
聚合根 划定事务一致性边界
仓储模式 隔离领域层和基础设施层

战略设计 (这一篇)解决的是"多个限界上下文之间怎么协作":

战略设计工具 解决什么问题
限界上下文 给"同一个词"画语义边界
子域分类 识别核心域 vs 支撑域 vs 通用域
上下文映射 定义上下文之间的协作关系
防腐层 保护下游上下文不被上游污染
开放主机服务 让上游上下文稳定输出

五、战略设计怎么落地?------一个完整示例

回到开头的例子:订单团队和商品团队为"分类"吵架。

用战略设计重新看这个问题:

第一步:识别限界上下文

  • 订单上下文:关注"交易快照"------下单那一刻的商品信息(名称、价格、数量)
  • 商品上下文:关注"商品本身"------分类体系、规格参数、上下架状态

第二步:定义上下文映射

订单上下文需要展示商品分类名称 → 它是下游 ,商品上下文是上游。

  • 商品上下文提供开放主机服务 :/open-api/v1/products/{id}/category
  • 订单上下文实现防腐层:调用这个API,把返回的分类信息转成自己需要的格式

第三步:各自独立演进

  • 商品上下文改了分类结构(从两级变成三级)→ 只需要改OHS的返回格式,订单上下文的ACL跟着适配
  • 订单上下文想展示更多商品信息(比如增加"品牌")→ 只需要在ACL里加一个字段,商品上下文完全不知道

代码长这样:

java 复制代码
// === 上游:商品上下文 ===
// 文件: product/interfaces/openapi/ProductOpenApi.java
@RestController
@RequestMapping("/open-api/v1/products")
public class ProductOpenApi {
    @GetMapping("/{id}/category")
    public CategoryDto getCategory(@PathVariable Long id) {
        // 商品上下文自己的Category模型
        Category category = productService.getCategory(id);
        // 转成公开的DTO返回
        return new CategoryDto(category.getId(), category.getName());
    }
}

// === 下游:订单上下文 ===
// 文件: order/infrastructure/acl/ProductCategoryTranslator.java
@Component
public class ProductCategoryTranslator {
    @Autowired private ProductOpenApiClient client;
    
    // 把商品上下文的CategoryDto转成订单上下文能理解的格式
    public OrderCategory toInternal(CategoryDto external) {
        // 订单上下文只需要分类名称,不需要id和其他信息
        return new OrderCategory(external.getName());
    }
}

// 文件: order/domain/order/OrderItem.java
public class OrderItem {
    private Long productId;
    private String productName;
    private BigDecimal price;
    private Integer quantity;
    private OrderCategory category;  // 订单上下文自己的分类模型
    
    // 订单上下文的业务逻辑------跟商品上下文完全无关
    public Money calculateSubtotal() {
        return new Money(price.multiply(BigDecimal.valueOf(quantity)));
    }
}

结果:

  • 两个团队各干各的,互不干扰
  • "分类"这个词在两个上下文里有不同的含义和不同的代码实现------各说各话,但各不越界
  • 谁都不用为对方的改动买单

六、什么时候需要战略设计?

不是所有项目都需要战略设计。判断标准很简单:

项目规模 需要什么 原因
1个团队,1个代码库 战术设计就够了 大家都在同一个上下文里,用同一套语言
2-3个团队,1个代码库 开始考虑战略设计 不同团队对同一概念的理解开始出现分歧
3个以上团队,多个代码库 必须做战略设计 没有限界上下文,协作会变成灾难

战术设计解决"怎么把代码写清楚"。战略设计解决"怎么把系统切明白"。

七、核心总结:一张表讲透战略设计

如果你没时间读完全文,读完这张表就够了。

概念 是什么 解决什么问题 一句话解释
限界上下文 语义和语境的边界 "商品"在订单里和库存里不是同一个东西 给同一个词画边界
核心域 公司最核心的竞争力 知道精力该花在哪里 最重要的事只有一件
防腐层(ACL) 下游翻译上游的模型 防止上游的"脏模型"污染自己 翻译官
开放主机服务(OHS) 上游提供统一的公开API 让所有下游用同样的方式调用 公共机场
上下文映射 描述上下文之间的关系 知道谁依赖谁、怎么依赖 系统间的关系地图

最后说一个可能得罪人的判断

没有战略设计的DDD,是假DDD。

很多团队学了充血模型、学了值对象、学了仓储模式,就觉得自己在做DDD了。但打开项目一看------所有的代码都在一个模块里,所有的"上下文"都混在一起,订单的Product和商品的Product是同一个类。

这叫 "战术DDD" ------只用了一半。

真正的DDD,是从战略设计开始的。先划分限界上下文,再在每一个上下文内部做战术设计。战略设计决定"切几刀",战术设计决定"每一刀怎么切" 。

如果你正在做一个需要多个团队协作的大型系统,先花两周做事件风暴,把限界上下文画出来。这比你先写三个月代码再回头重构,要划算得多。

相关推荐
一 乐5 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
卓怡学长5 小时前
w214基于jsp知道特产网
java·intellij-idea
步行cgn5 小时前
Spring 注解使用详解
java·spring
一条小小yu6 小时前
Spring IoC的理解
java·后端·spring
落魄实习生6 小时前
Agent Scope Java 2.x 系列【7】工具使用
java·开发语言·ai
旺仔学长 哈哈6 小时前
springboot钓鱼爱好者交流平台APP设计与实现
java·spring boot·mysql·充电桩管理系统
萧瑟余晖6 小时前
Dubbo SPI扩展机制详解
架构·dubbo
Shulex6 小时前
面向跨境电商多渠道消息系统的技术架构:亚马逊站内信合规对接与自动化执行链路设计
运维·架构·自动化
茉莉玫瑰花茶7 小时前
GO [ 方法 ]
开发语言·后端·golang
Wx-bishekaifayuan7 小时前
django个性化旅游路线推荐平台49005-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游