写在前面
在微服务系列第三篇里,我讲服务拆分时反复提到一个词——限界上下文,它就来自 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 代码。