《领域驱动设计》精简版:一张图看懂 DDD,引用开源 ddd-demo
- 约 22 分钟读完 - 全文 8619 字《领域驱动设计》精简版:一张图看懂 DDD,引用开源 ddd-demo
这份笔记基于 Eric Evans 的《领域驱动设计》以及 Vaughn Vernon 的《实现领域驱动设计》《领域驱动设计精粹》,结合日常工程实践做了补充。文中完整代码实现参考开源项目 soulnew/ddd-demo,可直接克隆运行。
一、为什么需要 DDD?
传统分层架构(Controller → Service → DAO)的问题:
| 问题 | 表现 |
|---|---|
| 贫血模型 | Service 里全是 if/else,Entity 只有 getter/setter |
| 语义泄漏 | 业务术语被翻译成 status = 1,新人读不懂 |
| 边界模糊 | 一个 OrderService 同时处理库存、支付、物流 |
| 复用困难 | 同样叫“用户”,在会员、权限、订单里含义不同 |
DDD 的核心主张:让软件结构对齐业务领域,用业务语言写代码,用边界保护领域模型。
二、一张图:DDD 战略-战术全景
mindmap
root((领域驱动设计 DDD))
战略设计 Strategic Design
领域 Domain
子域 Subdomain
核心域 Core
支撑域 Supporting
通用域 Generic
限界上下文 Bounded Context
上下文映射 Context Mapping
合作关系 Partnership
共享内核 Shared Kernel
客户-供应商 Customer-Supplier
遵奉者 Conformist
防腐层 ACL
开放主机服务 OHS
发布语言 PL
各行其道 Separate Ways
战术设计 Tactical Design
实体 Entity
值对象 Value Object
聚合 Aggregate
领域服务 Domain Service
仓储 Repository
工厂 Factory
领域事件 Domain Event
应用服务 Application Service
统一语言 Ubiquitous Language
分层架构 Layered Architecture
用户界面层 UI
应用层 Application
领域层 Domain
基础设施层 Infrastructure
记忆口诀:先战略(划边界),后战术(建模型),全程讲“统一语言”。
三、战略设计:先画地图,再打仗
3.1 领域 = 问题空间 + 解决方案空间
graph LR
A[问题空间<br/>Problem Space] --> B[领域 Domain<br/>业务要解决的问题]
B --> C[子域 Subdomain<br/>可独立讨论的业务板块]
C --> D[核心域 Core Domain<br/>竞争优势]
C --> E[支撑域 Supporting<br/>定制但非核心]
C --> F[通用域 Generic<br/>可外购/复用]
D --> G[解决方案空间<br/>Solution Space]
E --> G
F --> G
G --> H[限界上下文 Bounded Context<br/>一个模型只在一个上下文内有效]
示例:在线医疗平台
| 子域 | 类型 | 说明 |
|---|---|---|
| 问诊 | 核心域 | 核心竞争力,需精研模型 |
| 处方 | 支撑域 | 受监管约束,需定制但非差异化 |
| 支付 | 通用域 | 可直接对接微信/支付宝 |
| 消息通知 | 通用域 | 短信/推送可用第三方 |
3.2 限界上下文 Bounded Context
定义:限界上下文是领域模型能够保持一致性和完整性的显式边界。在边界内部,每个业务术语、概念、规则都有精确且唯一的含义;同一概念在不同限界上下文中可以拥有不同定义。它是 DDD 战略设计中保护模型不被语义污染的最小单元。
同一个业务名词,在不同上下文里含义可能完全不同。DDD 用“限界上下文”给模型画边界。
graph TB
subgraph 商品上下文 Product Context
P1[商品 = SKU + 价格 + 库存]
end
subgraph 订单上下文 Order Context
P2[商品 = 快照 + 购买时价格 + 数量]
end
subgraph 物流上下文 Logistics Context
P3[商品 = 重量 + 体积 + 易碎标识]
end
subgraph 营销上下文 Marketing Context
P4[商品 = 卖点 + 优惠券适用规则]
end
P1 -.->|映射| P2
P1 -.->|映射| P3
P1 -.->|映射| P4
关键认知:不要追求“一个全局统一的数据模型”,要在上下文内部统一,在上下文之间显式映射。
3.3 上下文映射模式
定义:上下文映射模式是 DDD 中用于描述不同限界上下文之间集成关系与协作方式的战略模式集合。由于每个限界上下文都有自己的模型和统一语言,当业务流程跨越多个上下文时,需要通过上下文映射来显式定义它们如何依赖、转换与协作,从而保证边界清晰、演化可控。
graph LR
A[上下文 A] -->|合作关系<br/>Partnership| B[上下文 B]
C[上下文 C] -->|共享内核<br/>Shared Kernel| D[上下文 D]
E[上游 Upstream] -->|客户-供应商<br/>Customer-Supplier| F[下游 Downstream]
G[强势上游] -->|遵奉者<br/>Conformist| H[下游]
I[下游] -->|防腐层 ACL| J[外部系统]
K[开放主机服务 OHS<br/>+ 发布语言 PL] --> L[下游]
M[上下文 M] -.->|各行其道<br/>Separate Ways| N[上下文 N]
| 模式 | 使用场景 |
|---|---|
| 合作关系 | 两个团队互相依赖,共同演进 |
| 共享内核 | 核心模型由双方共同维护,改动需同步 |
| 客户-供应商 | 上游优先服务下游需求,下游有话语权 |
| 遵奉者 | 上游强势(如外部 SaaS),下游无条件接受 |
| 防腐层 ACL | 隔离遗留系统或外部 API,保护领域模型 |
| 开放主机服务 + 发布语言 | 上游提供稳定 API/协议,供多个下游消费 |
| 各行其道 | 关联微弱,各自独立 |
3.4 从限界上下文到微服务边界
限界上下文是语义边界——一个模型在这个边界内含义一致;微服务是部署边界——独立代码库、独立数据库、独立发布单元。二者不是一回事,但 DDD 划分微服务边界时,最可靠的起点就是限界上下文。
映射原则
graph LR
A[业务领域] --> B[识别子域<br/>Core / Supporting / Generic]
B --> C[定义限界上下文<br/>语义边界]
C --> D{是否满足<br/>拆分条件?}
D -->|是| E[一个微服务]
D -->|否| F[多个上下文合并<br/>或模块化单体]
E --> G[独立数据库<br/>独立部署<br/>API / 事件交互]
默认规则:一个限界上下文对应一个微服务。
例外情况:
| 情况 | 处理 |
|---|---|
| 上下文内部模块差异极大(性能、安全、团队) | 可进一步拆分,但要保留相同的“统一语言”子空间 |
| 多个上下文非常小且高度协作 | 可合并为一个服务,内部用模块隔离 |
| 团队规模小、业务简单 | 先模块化单体,随演进再拆 |
| 强一致事务需求 | 优先放在同一服务 / 同一数据库内 |
拆分的启发条件
满足以下任意一条,都可以把限界上下文拆成独立微服务:
- 独立演进:团队能独立发布,不阻塞他人。
- 独立伸缩:流量特征不同,例如订单读多写少,库存强一致写频繁。
- 独立故障域:一个服务故障不应拖垮整个平台。
- 独立技术栈:某些上下文需要特殊存储(图数据库、时序数据库等)。
- 合规隔离:支付、医疗、金融数据需要独立部署和审计。
案例:B2C 电商的微服务边界划分
以常见的 B2C 电商为例,演示“子域 → 限界上下文 → 微服务”的完整推导过程。
第一步:识别子域
| 子域 | 类型 | 说明 |
|---|---|---|
| 订单 | 核心域 | 下单、状态机、优惠计算 |
| 库存 | 核心域 | 实时库存、预占、扣减 |
| 支付 | 通用域 | 对接微信 / 支付宝 / 信用卡 |
| 物流 | 支撑域 | 运单、轨迹、配送规则 |
| 商品 | 核心域 | 类目、属性、价格 |
| 营销 | 支撑域 | 优惠券、满减、秒杀 |
| 会员 | 支撑域 | 积分、等级、权益 |
第二步:定义限界上下文与统一语言
同一个业务名词在不同上下文里含义不同,这正是拆分的语义依据。
| 上下文 | “商品”在该上下文中的含义 | “订单”在该上下文中的含义 |
|---|---|---|
| 商品上下文 | SKU、SPU、类目、主图、售价 | 无 |
| 订单上下文 | 商品快照(下单时的价格和描述) | 聚合根:状态、金额、行项 |
| 库存上下文 | 可售数量、预占数量、仓库 | 无 |
| 物流上下文 | 重量、体积、易碎标识 | 发货单、包裹、运单 |
| 营销上下文 | 适用优惠券的商品范围 | 优惠分摊、实付金额 |
关键判断:如果两个地方对“商品”的定义和生命周期完全不同,就应分属不同上下文,也倾向于拆成不同微服务。
第三步:映射为微服务
graph TB
subgraph 微服务边界
P[商品服务<br/>Product Service]
O[订单服务<br/>Order Service]
I[库存服务<br/>Inventory Service]
PAY[支付服务<br/>Payment Service]
L[物流服务<br/>Logistics Service]
M[营销服务<br/>Marketing Service]
U[会员服务<br/>Member Service]
end
O -->|预占库存| I
O -->|发起支付| PAY
I -->|OrderPaid 事件| L
O -->|查询商品信息| P
O -->|计算优惠| M
U -->|积分抵扣| O
第四步:选择上下文映射模式
| 关系 | 模式 | 说明 |
|---|---|---|
| 订单 → 库存 | 客户-供应商 + 最终一致 | 订单调用库存预占接口;库存发布 InventoryReserved 事件 |
| 订单 → 支付 | 开放主机服务 OHS | 支付提供标准支付网关 API,多个业务共用 |
| 库存 → 物流 | 发布语言 PL + 领域事件 | OrderPaid 事件触发物流创建发货单 |
| 订单 → 商品 | 遵奉者 / ACL | 商品模型由商品服务定义,订单只保存快照 |
| 营销 → 订单 | 共享内核 Shared Kernel | 优惠分摊算法需双方共同维护 |
常见反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 按 CRUD 实体拆分 | UserService、OrderService、ProductService 只是数据表代理 |
按业务能力(上下文)拆分,服务内包含完整业务规则 |
| 按前端页面拆分 | 一个页面一个服务 | 页面是组合层,服务应按领域聚合 |
| 过度拆分 | 一次下单调 20 个接口 | 先模块化单体,验证边界后再拆 |
| 共享数据库 | 多个服务直接操作同一张表 | 每个服务拥有自己的数据,通过 API / 事件交互 |
一句话:微服务边界不是拍脑袋定的,而是从“限界上下文”出发,结合团队、性能、合规、发布节奏再微调的结果。
四、战术设计:在限界上下文里建模型
4.1 核心概念关系图
classDiagram
class AggregateRoot {
+Entity root
+invariant()
}
class Entity {
+identity
+continuity
}
class ValueObject {
+immutable
+equality by value
}
class DomainEvent {
+eventId
+occurredOn
+payload
}
class Repository {
+save(aggregate)
+byId(id)
}
class DomainService {
+cross-aggregate logic
}
class Factory {
+create()
}
AggregateRoot --> Entity : contains
AggregateRoot --> ValueObject : contains
AggregateRoot --> DomainEvent : publishes
Repository --> AggregateRoot : persists
Factory --> AggregateRoot : creates
DomainService --> AggregateRoot : orchestrates
4.2 实体 Entity vs 值对象 Value Object
graph LR
subgraph Entity
E1[Order #1001] --> E2[状态变化<br/>待支付 → 已发货 → 已完成]
E2 --> E3[同一 identity<br/>状态变了还是它]
end
subgraph Value Object
V1[Money amount=100 currency=CNY] --> V2[不可变]
V2 --> V3[替换即新生<br/>money = money * 2]
end
判断标准:
- 如果对象需要被追踪生命周期,有独立身份 → 实体
- 如果对象只描述属性,换了值就等于换了对象 → 值对象
五、完整设计 Demo:电商订单上下文
完整的代码实现可参考开源项目:
Gitee: soulnew/ddd-demo
https://gitee.com/soulnew/ddd-demo
该项目演示了一个典型的 DDD 工程结构,包括:
- 领域层:实体、值对象、聚合根、领域服务、领域事件
- 应用层:应用服务、Command/Query、事务编排
- 基础设施层:仓储实现、外部 API 适配、消息发布
- 接口层:Controller / API 入口
建议结合本节的战术概念,重点阅读:
domain/下的聚合根如何封装业务规则(如订单状态机)。application/下的应用服务如何编排用例,不写业务规则。infrastructure/下的仓储实现如何把领域模型映射到持久化。
如果你想快速跑通一个最小可运行示例,也可以直接克隆该项目:
git clone https://gitee.com/soulnew/ddd-demo.git下面只保留一个最小示意图,帮助理解“充血模型”与“贫血模型”的关键差异。
5.1 充血模型 vs 贫血模型对比
| 贫血模型 | 充血模型 |
|---|---|
OrderService 里写 if (status.equals("PAID")) |
Order.pay() 自己封装状态机 |
OrderEntity 只有 getter/setter |
Order 有业务方法,规则集中在领域对象 |
| 规则散落在 Service | 不变量、状态变更由聚合根保证 |
| 持久化即领域模型 | 持久化是领域模型的投影 |
核心原则:把“什么样合法”的判断放到领域对象里,应用服务只负责“什么时候调用”。
六、分层架构:代码放哪里?
graph TB
subgraph 用户界面层 UI/API
U1[Controller / Handler]
end
subgraph 应用层 Application
A1[Application Service]
A2[DTO / Command / Query]
end
subgraph 领域层 Domain
D1[Entity]
D2[Value Object]
D3[Domain Service]
D4[Domain Event]
D5[Repository Interface]
end
subgraph 基础设施层 Infrastructure
I1[Repository Implementation]
I2[Message Queue]
I3[Database]
I4[External API Client]
end
U1 --> A1
A1 --> D1
A1 --> D3
D1 --> D2
D1 -.->|发布| D4
A1 --> D5
D5 -.->|由| I1
I1 --> I3
A1 -.->|发送| I2
A1 -.->|调用| I4
依赖方向:UI → 应用 → 领域;基础设施通过接口被领域依赖(依赖倒置)。
| 层级 | 职责 | 不该做的事 |
|---|---|---|
| UI | 接收请求、返回响应 | 不直接操作 Repository |
| 应用层 | 编排用例、事务、权限校验 | 不写业务规则 |
| 领域层 | 业务规则、不变量、状态变更 | 不依赖框架/数据库 |
| 基础设施 | 持久化、消息、外部调用 | 不侵入领域规则 |
七、聚合设计:四条黄金规则
graph TB
subgraph 聚合根 Order
O1[Order] --> L1[OrderLineItem]
O1 --> L2[OrderLineItem]
O1 --> M1[Money total]
O1 --> A1[Address shipping]
O1 --> E1[DomainEvent list]
end
subgraph 外部只能通过聚合根访问
X[其他上下文] -->|byId/order.pay| O1
X -.->|❌ 不能直接修改| L1
end
- 一个聚合只有一个聚合根,外部对象只能引用聚合根 ID。
- 聚合内部强一致,跨聚合最终一致(用领域事件)。
- 小聚合优先,大聚合会成性能瓶颈和并发热点。
- 删除聚合根级联删除其内部对象。
反例:如果把 User 和 Order 放在同一个聚合里,用户每次下单都会锁定整个用户聚合,极端不合理。
八、领域事件:跨聚合/上下文的胶水
sequenceDiagram
participant O as Order 聚合
participant E as Event Bus
participant I as Inventory 上下文
participant L as Logistics 上下文
O->>O: order.pay()
O->>E: publish OrderPaid
E->>I: consume OrderPaid
I->>I: 扣减库存
E->>L: consume OrderPaid
L->>L: 创建发货单
领域事件的价值:
- 解耦:Order 不需要知道 Inventory 和 Logistics 的存在。
- 最终一致:接受短时间不一致,换取可扩展性。
- 审计:事件日志本身就是业务轨迹。
九、常见反模式与纠正
| 反模式 | 现象 | 纠正 |
|---|---|---|
| 贫血领域模型 | Entity 只有字段,Service 全是逻辑 | 把不变规则和状态变更放回实体 |
| 万能聚合 | 一个聚合包含十几个实体 | 按业务不变量拆分,跨聚合用事件 |
| 数据库驱动建模 | 先建表,再写代码 | 先建模,再映射持久化 |
| 跨上下文直接引用 | Order 直接持有 Inventory 对象 | 只存 ID,通过 ACL/OHS 交互 |
| 应用层写业务规则 | if order.status == ... 散落在 Service |
下放到领域对象 |
| 值对象可变 | money.setAmount(100) |
用 final 类 + 不可变字段,替换而非修改 |
十、什么时候用 DDD,什么时候不用?
flowchart TD
A[项目是否复杂?] -->|否| B[简单 CRUD 即可]
A -->|是| C[业务是否快速变化?]
C -->|否| D[事务脚本 / 简单分层]
C -->|是| E[团队是否愿意投入统一语言?]
E -->|否| F[DDD 收益低]
E -->|是| G[DDD 值得尝试]
G --> H[从核心域开始<br/>小步迭代]
适合 DDD:
- 业务复杂、领域专家与开发需紧密协作
- 核心域需要长期演进
- 微服务边界模糊,需要战略设计
不适合 DDD:
- 简单数据录入/展示系统
- 生命周期短、需求稳定的项目
- 团队没有领域专家时间投入
十一、实战案例:新项目从 0 到 1 落地 DDD
前面讲的都是概念和 Demo,下面以 在线健身私教平台 FitCoach 为例,把“如何划分领域、子域、限界上下文、微服务,以及如何决策充血/贫血模型、如何裁剪 DDD”串成一条完整思路。
11.1 项目背景与目标
FitCoach 要做一个连接私教与学员的平台,核心能力包括:
- 学员购买课程包、预约上课时间、查看训练记录
- 教练管理学员、制定训练计划、记录每次上课内容
- 运营人员配置课程 SKU、促销活动、处理退款申诉
- 对接第三方支付、短信推送、以及第三方直播上课工具
业务不算极端复杂,但涉及“课程、预约、课时、计划、会员、支付、营销”多个概念,且预计会快速迭代。这正是 DDD 能帮上忙的地方:先把业务地图画清楚,再决定怎么写代码。
11.2 第一步:识别领域与子域
领域(Domain) 就是整个健身私教业务要解决的问题空间。通过事件风暴或用户旅程,可以识别出以下子域:
| 子域 | 类型 | 判断理由 | 投入策略 |
|---|---|---|---|
| 私教课程与预约 | 核心域 | 直接决定用户体验和变现效率 | 投入最好的领域专家和工程师 |
| 训练计划与记录 | 核心域 | 教练专业能力的载体,差异化关键 | 与课程域紧密协作演进 |
| 会员与教练档案 | 支撑域 | 需要定制,但不是竞争壁垒 | 内部实现,够用即可 |
| 支付与财务 | 通用域 | 行业通用能力 | 对接微信/支付宝,封装标准接口 |
| 消息通知 | 通用域 | 短信/推送/站内信 | 使用第三方 SaaS |
| 内容(文章/视频) | 支撑域 | 运营辅助,提升留存 | 可用开源 CMS 改造 |
划分子域的权衡要点:
- 不要从技术模块出发。例如不要先想“需要一个 UserService”,而要想“会员管理”这个业务能力。
- 核心域通常只有 1-2 个。如果把所有子域都标成核心,等于没有重点。
- 通用域不要自己造轮子。支付、通知直接买,团队精力留给核心域。
- 子域边界可以随业务演化。MVP 阶段可以把“训练计划”放在“课程域”里,等业务复杂后再拆出来。
11.3 第二步:划分限界上下文
划分的核心问题是:同一个业务名词,在不同地方含义是否不同?生命周期是否独立?
以“课程”这个词为例:
| 上下文 | “课程”在该上下文中的含义 | 关键概念 |
|---|---|---|
| 课程目录上下文 | 可售卖的课程 SKU:价格、有效期、适用人群、退费规则 | CourseCatalog, PricePolicy |
| 预约调度上下文 | 一次具体的上课时间:教练、学员、时段、状态 | Appointment, TimeSlot |
| 训练记录上下文 | 一次实际上课产生的记录:消耗课时、训练内容、教练反馈 | TrainingRecord, ConsumedSession |
| 营销上下文 | 参与促销的课程范围:满减、套餐、优惠券适用 | PromotionRule, Bundle |
| 财务上下文 | 课程订单的应收、实收、退款流水 | CourseOrder, Refund |
因为“课程”的含义和生命周期在每个上下文里都不同,所以应该拆成不同的限界上下文。FitCoach 最终划分如下:
- 课程目录上下文(Course Catalog Context)
- 预约调度上下文(Scheduling Context)
- 训练记录上下文(Training Record Context)
- 会员上下文(Member Context)
- 教练上下文(Coach Context)
- 支付上下文(Payment Context)
- 营销上下文(Marketing Context)
- 消息通知上下文(Notification Context)
关键判断:如果两个地方对同一个名词的定义和生命周期不同,就倾向于拆成不同上下文;如果概念高度一致且总是一起变化,可以合并。
11.4 第三步:推导微服务边界
默认规则是一个限界上下文对应一个微服务,但 FitCoach 还要考虑团队规模、数据一致性、发布频率、性能特征。
| 限界上下文 | 微服务决策 | 决策理由 |
|---|---|---|
| 课程目录 | 独立服务 | 变化频繁,运营经常调整 SKU 和价格 |
| 预约调度 | 独立服务 | 高并发读,学员大量刷可选时段,需单独伸缩 |
| 训练记录 | 独立服务 | 写多读少,数据量大,未来可能要独立归档 |
| 会员 + 教练档案 | 合并为“账户服务” | MVP 阶段都很薄,且登录后经常一起查询;内部用模块隔离 |
| 支付 | 独立服务 | 涉及资金安全、合规审计、对接第三方 |
| 营销 | 独立服务 | 活动频繁,不能影响核心预约链路 |
| 消息通知 | 独立服务 | 可被多个业务复用,适合独立演进 |
合并的例外处理:
- 会员和教练虽然业务概念不同,但 MVP 阶段数据模型简单,合并可以减少一次远程调用。
- 如果后续教练业务复杂化(排期、评级、分成、资质认证),再把教练上下文拆成独立服务。
一句话:微服务边界从限界上下文出发,再结合团队、性能、合规、数据一致性做微调。
11.5 第四步:选择上下文映射模式
不同上下文之间不是孤立的,需要显式定义它们如何协作。
graph LR
A[预约上下文] -->|Customer-Supplier| B[课程目录上下文]
C[训练记录上下文] -->|Customer-Supplier| A
D[支付上下文] -->|OHS + PL| E[预约上下文]
F[营销上下文] -->|Shared Kernel| A
G[消息通知上下文] -->|Conformist| H[短信/推送第三方]
I[账户服务] -->|OHS + PL| J[所有业务服务]
K[训练记录上下文] -->|ACL| L[直播上课工具]
| 关系 | 模式 | 说明 |
|---|---|---|
| 预约 → 课程目录 | 客户-供应商 | 预约需要课程的名称、价格、时长;课程目录提供稳定查询接口 |
| 训练记录 → 预约 | 客户-供应商 | 训练记录基于已完成的预约生成 |
| 支付 → 各业务 | 开放主机服务 OHS | 支付提供统一的收款、退款、对账 API |
| 营销 → 预约 | 共享内核 Shared Kernel | 促销分摊、优惠券核销规则需要双方共同维护 |
| 消息通知 → 第三方 | 遵奉者 Conformist | 直接调用第三方 SDK,模型由第三方定义 |
| 训练记录 → 直播工具 | 防腐层 ACL | 直播工具接口不稳定,通过 ACL 隔离,保护领域模型 |
11.6 第五步:充血模型 vs 贫血模型决策
DDD 并不要求全场充血。模型选择应该看该上下文的业务复杂度,而不是追求统一风格。
| 上下文 | 推荐模型 | 理由 |
|---|---|---|
| 预约调度 | 充血模型 | 状态机复杂:待预约 → 已预约 → 已签到 → 已完成 → 已取消;有取消规则、爽约规则 |
| 训练记录 | 充血模型 | 记录不可变,但课时消耗计算、教练评分聚合有业务规则 |
| 课程目录 | 贫血模型可接受 | 主要是 CRUD,业务规则少 |
| 账户服务 | 贫血模型 | 数据管理为主,验证规则简单 |
| 消息通知 | 贫血/过程式 | 基本是发送逻辑,无复杂领域规则 |
| 营销 | 充血模型 | 优惠计算、互斥规则、分摊逻辑复杂 |
预约上下文的充血模型示例:
public class Appointment {
private AppointmentId id;
private TimeSlot timeSlot;
private MemberId memberId;
private CoachId coachId;
private AppointmentStatus status;
private List<DomainEvent> domainEvents = new ArrayList<>();
public void cancel(LocalDateTime now) {
if (status != AppointmentStatus.BOOKED) {
throw new DomainException("只能取消已预约的课程");
}
if (timeSlot.start().minusHours(24).isBefore(now)) {
throw new DomainException("开课 24 小时内不可取消");
}
this.status = AppointmentStatus.CANCELLED;
domainEvents.add(new AppointmentCancelled(id, now));
}
public void checkIn(LocalDateTime now) {
if (status != AppointmentStatus.BOOKED) {
throw new DomainException("未预约不能签到");
}
this.status = AppointmentStatus.COMPLETED;
domainEvents.add(new AppointmentCheckedIn(id, memberId, coachId, now));
}
}决策口诀:
- 状态机复杂、不变量多、业务规则变化快 → 充血模型
- 主要是增删改查、数据为主、规则简单 → 贫血模型即可
- 不确定时,先写贫血,等规则复杂了再重构为充血
11.7 第六步:DDD 是否可以裁剪?
可以,而且应该根据项目情况裁剪。 DDD 不是全有或全无的框架。
场景 1:只用战略设计,战术设计从简
- 适合情况:业务复杂但技术栈简单、团队经验不足、MVP 阶段要快速上线。
- FitCoach 初期做法:先画好限界上下文和微服务边界,服务内部用简单 CRUD + Service 实现。
- 价值:至少解决了“边界混乱、术语不一致、重复造轮子”的问题。
场景 2:只用战术设计,在已有边界内建模
- 适合情况:系统边界已经清楚,但某个模块内部逻辑混乱(如遗留系统重构)。
- FitCoach 示例:课程目录已经稳定,但预约状态机满是 if/else,可以只对“预约上下文”做充血模型重构。
- 风险:如果战略边界本身不清晰,战术设计会变成“在错误的地方做正确的事”。
场景 3:战略 + 战术都用,但不用高级模式
- 这是最常见的落地方式。
- FitCoach 可以用:限界上下文 + 充血模型 + 领域事件 + 分层架构。
- 可以先不用:事件溯源、CQRS、六边形架构的严格形式。
裁剪原则
- 复杂的地方用 DDD,简单的地方不要硬套。
- 先战略后战术,边界不清不要深入建模型。
- 一个项目可以不同上下文用不同成熟度:核心域充血 + 贫血,通用域直接用 SDK。
- 不要为了 DDD 而 DDD,目标是降低业务复杂度,不是写出“DDD 标准代码”。
11.8 落地时间线建议
| 阶段 | 目标 | 关键产出 |
|---|---|---|
| Week 1-2 | 事件风暴 + 统一语言 | 子域图、限界上下文图、术语表 |
| Week 3 | 微服务边界与映射模式 | 服务拆分方案、接口契约初稿 |
| Week 4-6 | 核心域战术设计 | 聚合、实体、值对象、领域事件、测试 |
| Week 7-8 | 基础设施 + 集成测试 | 数据库、消息队列、CI/CD 流水线 |
| 持续 | 演进 | 根据业务变化调整边界和模型 |
新手常犯的错误:一上来就在所有服务里写聚合根、值对象、领域事件。正确做法是先画地图,再对核心域精雕细琢,其他域能跑通即可。
十二、一句话总结
DDD = 用“统一语言”沟通业务,用“限界上下文”划分边界并推导微服务边界,用“聚合/实体/值对象”封装领域知识,用“领域事件”连接上下文,让软件结构真正反映业务结构。
把 soulnew/ddd-demo 的 Demo 跑一遍,再画一张你自己业务的战略地图,并尝试标出可能的微服务边界,你就已经比 80% 自称做 DDD 的人更接近它的本质了。
参考
- Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software
- Vaughn Vernon. Implementing Domain-Driven Design
- Vaughn Vernon. Domain-Driven Design Distilled