领域驱动设计(一):战略设计与战术设计全景

写在前面

在微服务系列第三篇里,我讲服务拆分时反复提到一个词——限界上下文,它就来自 DDD(Domain-Driven Design,领域驱动设计)。当时埋了个伏笔,这篇就来把它讲透。

DDD 不是一种技术,而是一套应对复杂业务的方法论:怎么把真实世界的业务,忠实地映射成代码模型。很多人觉得 DDD 玄学,是因为一上来就扎进"实体、值对象、聚合"这些战术名词。其实 DDD 分两层——战略设计(拆分边界)比战术设计(建模积木)重要得多。

这篇是 DDD 系列第一篇,做全景导览。后续会单独深入聚合设计、事件风暴落地等。


一、DDD 要解决什么问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
软件失败的头号原因:不是技术选错,是"把业务理解错了"

  现实:业务专家说一套,开发理解一套,代码实现又一套
    → 三者逐渐分裂,代码越来越偏离业务真相
    → 改一个需求,发现代码里的概念和业务对不上
    → 最后系统变成"能跑但没人敢动"的黑洞

  DDD 的核心目标:让"业务知识"顺畅地流进"代码模型"
    → 业务专家和开发说同一种话(通用语言)
    → 代码结构忠实反映业务结构(领域建模)
    → 复杂度被控制在边界内(限界上下文)

  一句话:DDD 是关于"理解业务"的工程方法论。
1
2
3
4
5
什么时候该上 DDD:
  ✓ 业务复杂、规则多、领域知识深厚(金融、电商核心、ERP)
  ✗ CRUD 为主、逻辑简单的系统 → 用 DDD 是杀鸡用牛刀

  DDD 的代价不低,只有业务复杂度配得上时才值得。

二、通用语言(Ubiquitous Language)

DDD 的第一块基石,也是最容易被忽视的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
通用语言:业务专家和开发共用同一套词汇

  反例(没有通用语言):
    业务说"客户",代码里叫 User
    业务说"下单",代码里叫 createOrder
    业务说"退款",代码里叫 handleReturn
     沟通要 constantly 翻译,翻译就会失真

  通用语言:
    业务说的每个术语 = 代码里的类/方法/字段名
    "客户" 就是 Customer"下单" 就是 placeOrder
     沟通零翻译,代码读起来像业务文档

  怎么建立:业务专家和开发一起,把核心概念逐一命名、统一,
           写进文档、写进代码、写进测试。 disagreements 当场解决。

通用语言是 DDD 的灵魂。没有它,后面的实体、聚合都是空中楼阁——因为你自己都不知道在建模什么。


三、战略设计:划分边界

战略设计回答"系统怎么切"。这是 DDD 价值最大的部分。

3.1 领域与子域

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
领域(Domain):你的业务范围(如"电商")
子域(Subdomain):领域内的子问题,分三类:

  核心子域(Core)    —— 你的竞争优势,最复杂,最值得投入
    电商的:定价、促销、推荐
    → 自研,倾注最好的设计和人才

  支撑子域(Supporting)—— 必要但非差异化
    电商的:库存、物流
    → 可自研可采购,够用就行

  通用子域(Generic)  —— 谁都需要,无差异化
    电商的:认证、权限、通知
    → 直接用现成方案,别自研

  精力分配:核心域 > 支撑域 > 通用域
  常见错误:在通用域上花大力气自研,核心域反而草草了事。

3.2 限界上下文(Bounded Context)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
限界上下文:一个模型成立的"边界范围"

  同一个词,在不同上下文里含义不同:
    "商品"
      商品上下文:上架的 SKU(详情、图片、分类)
      订单上下文:下单时的快照(当时的价格、规格)
      配送上下文:要发货的物理货品(重量、体积、库位)
    → 三个"商品",三个上下文,三套模型。不能硬塞进一个 Product 类。

  限界上下文 = 模型的自治边界:
    - 内部有统一的通用语言
    - 内部模型自洽,不污染别的上下文
    - 对外提供清晰的业务能力

  价值:它天然就是"模块/微服务"的拆分单位
       (详见微服务系列第三篇:服务拆分第一性原理)

3.3 上下文映射(Context Mapping)

划清上下文后,还要理清它们之间怎么协作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
常见的上下文关系:

  合作关系(Partnership):两个上下文团队紧密协作,共同设计
    → 适合无法明确划分责任的场景

  共享内核(Shared Kernel):两个上下文共享一小块核心模型
    → 共享部分强耦合,改动要双方同意。慎用,容易退化成大泥球。

  客户-供应商(Customer-Supplier):上游供应,下游消费
    → 上游优先,但要照顾下游需求。明确谁是甲方。

  防腐层(Anti-Corruption Layer,ACL):下游建一层翻译
    → 防止上游(尤其是遗留系统)的烂模型"腐蚀"自己的干净模型
    → 接入第三方/老系统时的标准做法,极其重要。

  开放主机服务(OHS)/ 发布语言(PL):上游提供标准开放接口
    → 对外用一套稳定协议,屏蔽内部实现

  实战中最有用:ACL(防腐层)—— 任何接外部/遗留系统的地方都该建。

四、战术设计:建模积木

战略设计画好边界后,在单个上下文内部用战术积木来建模。这些是 DDD 最出名的名词,但记住:它们服务于战略,不是 DDD 的全部。

4.1 实体(Entity)

1
2
3
4
5
6
7
有"身份标识"(ID),即使属性全变,还是它自己。

  例:订单 Order,有 OrderId
    → 改了收货地址、改了状态,它还是"那一笔订单"
    → 两个 Order 即使所有字段相同,ID 不同就是两笔

  特征:有唯一 ID、有生命周期、靠 ID 判等
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 实体:靠 Id 判等,不靠属性
public class Order : Entity      // Entity 基类提供 Id 与判等
{
    public OrderId Id { get; }
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Address ShippingAddress { get; private set; }

    public void ChangeShipping(Address newAddress)
    {
        if (Status == OrderStatus.Shipped)
            throw new DomainException("已发货不能改地址");
        ShippingAddress = newAddress;
    }
}

4.2 值对象(Value Object)

1
2
3
4
5
6
7
8
没有身份,靠"属性的值"判等。不可变。

  例:地址 Address、金额 Money、坐标 Point
    → 两个 "北京市朝阳区" 是同一个地址,无需 ID 区分
    → 改地址 = 换一个新对象,不修改原对象(不可变)

  特征:无 ID、不可变、按值判等、通常很小
  价值:被严重低估。能用值对象就别用实体,能省掉大量 ID 管理。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 值对象:不可变,按值判等(两个金额相同就相等)
public readonly record struct Money(
    decimal Amount, string Currency)
{
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException("币种不同");
        return this with { Amount = Amount + other.Amount };
    }
}
// 用法:var total = price.Add(tax);   // 不改原对象,返回新的

4.3 聚合与聚合根(Aggregate & Aggregate Root)—— DDD 的核心

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
聚合:一组紧密相关的对象,作为一个"一致性边界"整体被访问。

  聚合根:聚合的入口对象,外部只能通过它访问聚合内部。

  例:订单聚合
    Order(聚合根)
      ├── OrderItem(订单项,若干)
      └── ShippingAddress(收货地址,值对象)

    规则:
      - 外部不能直接 new OrderItem 或直接拿 OrderItem 
      - 必须通过 Order.AddItem(...) / Order.RemoveItem(...)
      - 订单项只在自己所属的订单上下文里有意义

  聚合保证的"不变量"Invariants):
    例:订单总价 = 所有订单项小计之和
       库存不能为负
        这些规则由聚合根在内 部强制维护,外界绕不过去
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 聚合根:所有不变量在内部强制,外部无法绕过
public class Order : Entity, IAggregateRoot
{
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items;
    public Money Total => _items.Aggregate(
        new Money(0, Currency), (sum, i) => sum.Add(i.SubTotal));

    public void AddItem(ProductId product, Money price, int qty)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("非草稿态不能加项");
        if (qty <= 0)
            throw new DomainException("数量必须大于 0");

        var existing = _items.FirstOrDefault(i => i.ProductId == product);
        if (existing is not null) existing.Increase(qty);
        else _items.Add(new OrderItem(product, price, qty));
        // 不变量由聚合根维护,外部拿不到 _items 直接改
    }
}

聚合是 DDD 最关键也最容易用错的概念,第五节专门讲设计原则,后续会有整篇展开。

4.4 领域服务、领域事件、仓储、工厂

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
领域服务(Domain Service)
  承载"不属于任何单个实体"的业务逻辑
  例:转账 = 涉及两个账户,不该塞进某一个 Account
  → 封装成 TransferService.Transfer(from, to, amount)
  特征:无状态、纯领域逻辑

领域事件(Domain Event)
  记录"领域中发生的事",用于解耦
  例:OrderPlaced(订单已创建)→ 触发扣库存、加积分、发通知
  → 聚合做完自己的事,发事件,订阅者各自反应
  → 微服务系列第五篇讲过:这是事件驱动 + 最终一致的基础

仓储(Repository)
  聚合的"存取接口",屏蔽持久化细节
  对外提供 IOrderRepository.Find(id) / Save(order)
  实现在基础设施层(EF Core / Dapper),领域层只认接口
  → 一个聚合对应一个仓储,按"聚合根"整体存取,不按表

工厂(Factory)
  封装复杂对象的创建(保证创建时就满足不变量)
  简单创建用构造函数/静态工厂;复杂聚合用专门的 Factory

五、聚合设计原则(最容易踩的坑)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
1. 尽量设计小聚合
   错误:把"用户、订单、商品、评价"塞进一个大"用户聚合"
   → 一改全锁,性能差、并发冲突多
   正确:一个聚合 = 一致性边界,只包含必须一起变更的

2. 跨聚合引用用 ID,不用对象引用
   Order 引用 CustomerId,不直接持有 Customer 对象
   → 解耦,避免一个聚合加载整张图

3. 一个事务只修改一个聚合
   跨聚合的变更 → 用领域事件 + 最终一致(不靠一个事务硬保)
   → 这就是微服务系列第五篇 Saga / 事件驱动的由来

4. 不变量在聚合内部强一致,跨聚合最终一致
   核心:聚合边界 = 事务边界 = 一致性边界(三位一体)

   判断聚合边界的试金石:
     "这几个对象,必须在一个事务里一起改才能保证正确吗?"
     是 → 同一聚合;否 → 拆成不同聚合,用事件联动。

六、DDD 落地的常见误区

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
误区 1:贫血模型(Anemic Model)
  实体只有 getter/setter,业务逻辑全写在 Service 里
  → 实体退化成数据袋,丢失了"封装不变量"的精髓
  → 这其实是"事务脚本",不是 DDD

误区 2:为 DDD 而 DDD
  简单 CRUD 系统硬套聚合/仓储/领域事件
  → 复杂度暴涨,收益为零。CRUD 就老老实实 CRUD。

误区 3:战略缺失,只剩战术
  不画限界上下文,直接开始抠实体值对象
  → 战术在错误的边界里建模,比不用 DDD 还糟

误区 4:大聚合
  什么都往一个聚合塞,变成分布式时代的"巨型对象"

误区 5:仓储当 DAO 用
  按"表"建仓储(UserRepository 对应 User 表)
  → 仓储应该按"聚合根"建,整体存取,不是按表 CRUD

七、DDD 与微服务、分层架构的关系

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
DDD 不是孤岛,它和前面讲的所有架构概念是一体的:

  战略设计(限界上下文)
    = 微服务的拆分单位(微服务系列第三篇)
    = 模块化单体的模块边界(系列第二篇)

  领域事件
    = 事件驱动 / 最终一致的基础(系列第五篇)
    = Outbox 模式投递的内容

  聚合边界 = 事务边界 = 一致性边界
    = 数据一致性的设计起点(系列第五篇)

  仓储 + 接口隔离
    = 整洁架构/洋葱架构的核心(系列第八篇 .NET 落地)

  防腐层 ACL
    = 服务间通信的解耦手段(系列第六篇)

  → DDD 是"粘合剂",把前面散落的架构概念,在"业务建模"层面统一起来。
  → 这也是为什么把它放在架构系列里讲。

八、小结

  • DDD 的目标:让业务知识忠实流进代码模型,应对复杂业务
  • 通用语言是灵魂:业务和代码说同一套话,术语即类名
  • 战略设计 > 战术设计:领域/子域(核心/支撑/通用)、限界上下文、上下文映射(尤其防腐层)
  • 战术积木:实体(有 ID)、值对象(按值/不可变)、聚合 + 聚合根(一致性边界)、领域服务/事件/仓储/工厂
  • 聚合四原则:小聚合、跨聚合用 ID 引用、一事务一聚合、内部强一致跨聚合最终一致
  • 五大误区:贫血模型、为 DDD 而 DDD、战略缺失、大聚合、仓储当 DAO
  • DDD 是粘合剂:战略上下文 = 微服务边界;领域事件 = 最终一致基础;聚合 = 一致性边界

下一篇会深入聚合设计——怎么划聚合边界、聚合根该承担什么、跨聚合如何协作,附完整 .NET 代码。