3. 业务模块边界识别:DDD限界上下文、模块划分原则、高内聚低耦合

在Go接口契约设计中,最核心的挑战不是如何定义接口,而是在哪里定义接口。错误的边界划分会导致接口膨胀、循环依赖、以及无休止的上下文切换。本章将深入DDD限界上下文(Bounded Context)的识别方法,结合Go模块化实践,给出可落地的边界划分原则。

3.1 限界上下文:业务边界的原子单位

限界上下文是DDD中最重要的战术设计工具。它定义了一个语义一致的边界,在这个边界内部,领域模型、术语、业务规则都是统一的。跨上下文则通过上下文映射进行集成。

维度 限界上下文内部 跨限界上下文
术语一致性 统一语言(Ubiquitous Language) 翻译/映射
模型独立性 完全自治 依赖倒置/防腐层
数据所有权 独占数据存储 事件驱动/API调用
Go模块边界 独立go module 接口契约+依赖注入
实战判断标准:当两个业务概念在同一个对话中产生歧义时,它们就应该属于不同的限界上下文。例如“客户”在销售上下文指潜在客户,在售后上下文指已签约客户,这两个“客户”应分属不同上下文。

3.2 模块划分原则:从业务到代码的映射

将限界上下文映射为Go模块时,需要遵循以下四条核心原则:

  1. 单一职责原则(SRP):一个模块只应对一个业务角色负责。例如订单模块不应同时处理支付逻辑。
  2. 闭包原则(CCP):因相同原因变化的内容应放在同一模块。例如所有与汇率计算相关的逻辑应聚合在“汇率上下文”。
  3. 复用发布等价原则(REP):模块的粒度应与发布粒度一致。频繁独立发布的逻辑应拆为独立模块。
  4. 无循环依赖原则(ADP):模块依赖图必须是有向无环图(DAG)。Go编译器强制禁止循环导入,但业务层面也需避免。
Go模块划分清单:
  • 每个限界上下文对应一个go module,如github.com/company/order
  • 模块内按层分包:domain/、application/、infrastructure/、interfaces/
  • 跨模块依赖只允许通过domain层的接口契约进行
  • 禁止infrastructure层直接依赖其他模块的infrastructure层

3.3 高内聚低耦合:接口契约的边界守卫

高内聚低耦合不是抽象口号,而是可以通过接口契约量化的设计指标:

指标 高内聚表现 低耦合表现
接口方法数 3-5个方法,聚焦单一职责 超过10个方法,职责混杂
参数类型 使用本模块定义的领域类型 大量使用基础类型或外部模块类型
依赖方向 依赖抽象,不依赖实现 直接依赖具体结构体或第三方库
变更影响 修改只影响本模块内部 修改导致多个模块连锁变更
常见陷阱:不要为了“复用”而将两个业务逻辑不同的上下文合并。合并后的模块会同时承担多个变化原因,导致每次需求变更都要修改该模块,最终变成“上帝模块”。宁可接口重复,不要耦合混乱。

3.4 边界识别实战:订单与库存的上下文映射

以下SVG展示了电商系统中订单上下文与库存上下文的典型边界划分,以及它们通过接口契约进行交互的方式:

订单与库存限界上下文映射 订单与库存限界上下文映射 订单上下文 OrderService OrderRepository OrderDomainEvent Order (Aggregate Root) 库存上下文 InventoryService InventoryRepository StockReservationService Product (Aggregate Root) 接口契约层:InventoryReservationPort / OrderEventPublisher 调用 事件 两个上下文通过接口契约交互,各自维护独立的领域模型和数据存储

3.5 边界识别流程:四步法

在实际项目中,按以下四步识别并确认业务模块边界:

  1. 事件风暴(Event Storming):与业务专家一起,用便利贴列出所有领域事件,按时间线排列。
  2. 聚合识别:从事件反推触发该事件的命令(Command),将命令与事件之间的实体聚合为聚合根。
  3. 上下文分组:将关联紧密的聚合归入同一个限界上下文,跨上下文的关联通过事件或接口进行。
  4. 接口契约定义:为每个跨上下文交互点定义Go接口,接口放在消费方模块的domain层。
Go代码示例:跨上下文接口契约
// 订单上下文 - domain层定义库存预留接口
package order/domain

// InventoryReservationPort 库存预留端口(由订单上下文定义)
type InventoryReservationPort interface {
    // ReserveStock 预留库存,返回预留ID
    ReserveStock(ctx context.Context, sku string, quantity int) (string, error)
    // ConfirmReservation 确认预留(订单支付成功后调用)
    ConfirmReservation(ctx context.Context, reservationID string) error
    // CancelReservation 取消预留(订单超时或取消时调用)
    CancelReservation(ctx context.Context, reservationID string) error
}

// 库存上下文 - infrastructure层实现该接口
package inventory/infrastructure

type InventoryReservationAdapter struct {
    svc *inventory.InventoryService
}

func (a *InventoryReservationAdapter) ReserveStock(ctx context.Context, sku string, quantity int) (string, error) {
    return a.svc.Reserve(sku, quantity)
}
// ... 其他方法实现

3.6 边界错误的典型症状与修复

症状 根因 修复方案
模块A修改导致模块B编译失败 模块A暴露了内部实现细节 在模块A的domain层定义接口,模块B只依赖接口
一个模块包含超过5个聚合根 限界上下文过大 拆分为多个子上下文,每个子上下文2-3个聚合根
跨模块调用链超过3层 依赖关系混乱 引入事件总线或CQRS,减少同步调用链
单元测试需要mock 5个以上外部接口 模块耦合度过高 重新审视模块职责,将外部依赖集中到防腐层
边界健康度检查:在Go项目中,运行go mod graph查看模块依赖图。如果出现循环依赖或星型依赖(一个模块被几乎所有其他模块依赖),说明边界划分需要重构。

3.7 小结:边界即契约

业务模块边界不是物理上的代码分隔,而是语义契约的边界。每个限界上下文对外承诺一组稳定的接口契约,对内保持实现自由。Go语言的接口机制天然适合作为这种契约的载体——接口定义在消费方,实现由提供方完成,依赖方向与数据流向相反,这正是DDD中依赖倒置原则的完美体现。