从类爆炸到协作——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,是从战略设计开始的。先划分限界上下文,再在每一个上下文内部做战术设计。战略设计决定"切几刀",战术设计决定"每一刀怎么切"

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

相关推荐
程序员cxuan1 小时前
我用 DeepSeek-V4-Pro,完美复刻了苹果官网
人工智能·后端·程序员
wno7041 小时前
Spring Boot异常处理
java·spring boot·后端
君顾12 小时前
智慧场馆解决方案小程序系统开发实战指南
java·开发语言·智慧场馆
AI多Agent协作实战派2 小时前
AI多Agent协作系统实战(四十五):漏声明了一个变量,整个页面的按钮都死了
后端
学习星球2 小时前
【LeetCode算法题精讲】二分查找精讲
java·数据结构·算法·leetcode·职场和发展·图搜索
十八画生ovo2 小时前
拆开 Claude Code 的源码后发现:它只是一个 while 循环
架构·ai编程·claude
Escalating_xu2 小时前
【Linux线程同步】从数据竞争到 mutex、条件变量与生产者消费者(上篇)
android·java·linux
叫我Paul就好2 小时前
当你用过 Spring,你可能就更能理解 DSH 的核心 Cordis
后端·面试
承渊政道2 小时前
10:30提交预约会不会撞上10:00的会议?我用飞算JavaAI3.9.1和3.9.9跑了四个时间段
java·springboot·ai编程·飞算javaai·java代码生成