关于 domain-driven-design
面向开发者的领域建模技能,结合数据导向设计与类型驱动设计,让无效状态难以表示,并使代码贴合业务语言。
主要功能
- 01数据导向建模
- 02类型驱动的领域设计
- 03显式工作流与错误路径
- 04DDD 构建块与边界
- 05领域图示与术语表指导
适用场景
- 01用不可变数据建模业务领域
- 02通过类型表达有效状态与转换
- 03分离领域逻辑和基础设施
- 04记录领域术语与关系
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ bfollington/ terma --skill domain-driven-design安装后在 Claude Code 中输入/domain-driven-design确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/ddd_foundations_and_patterns.mdrich_hickey_principles.mdvisualization_examples.mdwlaschin_patterns.md详细介绍
领域驱动设计
概述
此技能基于 Rich Hickey 的数据导向设计原则和 Scott Wlaschin 的类型驱动设计方法,为领域建模提供指导。重点是构建让非法状态无法表示的系统,优先考虑数据和转换,而非对象和方法,并建立连接技术实现与业务领域的通用语言。
核心原则
Rich Hickey 的数据导向设计
简单胜于易用
- 优先使用可以独立理解的简单构造
- 避免复杂交织(相互纠缠)无关的关注点
- 将策略与机制、数据与行为分开
数据至上
- 使用纯数据结构而非包含行为的对象来建模领域
- 适当时优先使用通用数据结构(映射、集合、向量),而非自定义类
- 数据应能自我描述并可供检查
- 函数转换数据;数据不执行行为
值的价值
- 使用不可变值表示事实
- 值支持局部推理和简单相等性判断
- 值可以自由共享,无需协调
- 思考:这个领域中有哪些不可变事实?
解耦
- 识别领域中真正重要的内容与偶然产生的复杂性
- 将何时发生与发生什么分开
- 将机制与策略分开
- 思考:这些关注点实际上是彼此独立的吗,还是我们把它们纠缠在了一起?
Scott Wlaschin 的类型驱动设计
让非法状态无法表示
- 使用类型系统在编译时消除无效状态
- 显式建模可选值(Option/Maybe 类型)
- 对互斥状态使用和类型(判别联合)
- 避免原始类型偏执;创建领域专用类型
以函数式方式进行领域建模
- 将工作流建模为数据转换:输入 → 处理 → 输出
- 将业务规则显式建模为函数
- 将验证与业务逻辑分开
- 从以下角度思考:可能发生什么?有效的状态转换有哪些?
铁路导向编程
- 显式建模成功和失败路径(Result 类型)
- 使用 bind/flatMap 串联可能失败的操作
- 让正常路径清晰、线性
- 在适当的边界处理错误
类型即文档
- 类型签名应传达意图
- 使用 newtype 包装器来明确语义(UserId、EmailAddress、Timestamp)
- 使用类型将输入限制在有效范围内
- 让类型系统指导 API 设计
DDD 构建块
实体与值对象
实体由其标识而非属性定义:
- 拥有唯一标识符(ID、帐号等)
- 可以随时间变化,同时保持身份不变
- 属性相同但 ID 不同的两个实体彼此不同
- 在领域专家按名称/ID 指称事物时使用
值对象完全由其属性定义:
- 没有唯一标识符
- 不可变
- 属性相同的两个值对象可以互换
- 仅在值重要而身份不重要时使用
决策指南:
- 思考:领域专家会按 ID/名称指称它吗?→ 实体
- 思考:我能用等价副本替换它吗?→ 如果可以:值对象
聚合与聚合根
聚合:一组实体和值对象,在数据变更时作为一个整体处理。
聚合根:外部访问聚合时必须经过的唯一实体。
目的:
- 定义事务一致性边界
- 强制执行跨多个对象的业务不变式
- 将相关概念组合起来,简化模型
规则:
- 外部引用只能指向聚合根(使用 ID 引用)
- 根负责强制执行整个聚合的所有不变式
- 事务不能跨越聚合边界(使用最终一致性)
- 保持聚合规模小,以提升性能和可扩展性
以下情况不应创建聚合:
- 对象可以独立修改
- 没有需要事务一致性保障的共享不变式
- 不同对象具有不同的生命周期
限界上下文
定义:领域模型适用的明确边界。
目的:
- 将大型领域划分为易于管理的部分
- 允许同一术语在不同上下文中具有不同含义
- 防止混合不兼容的概念导致模型受损
关键认知:通用语言只在一个上下文中通用。在销售上下文中,“客户”可能与在配送上下文中的“客户”含义不同。
建模时:
- 确定当前所在的限界上下文
- 在代码结构中明确上下文边界(使用独立模块/命名空间)
- 跨上下文集成时使用防腐层
- 记录上下文之间的关系(上下文映射)
领域事件
定义:领域中发生的重要事情。
特征:
- 使用过去时命名(OrderPlaced、PaymentProcessed、UserRegistered)
- 不可变的事实
- 领域专家关注这些事件
- 可在上下文内部或跨上下文触发响应
用途:
- 解耦领域逻辑
- 实现聚合之间的最终一致性
- 集成限界上下文
- 事件溯源(将事件存储为事实来源)
仓储
目的:提供聚合的内存集合这一假象,并抽象持久化过程。
特征:
- 在聚合边界上运作(加载/保存整个聚合)
- 支持按 ID 查找
- 隐藏数据库实现细节
- 返回领域实体,而不是数据库行
模式:应用层使用仓储获取/保存聚合;领域层保持纯粹。
领域建模工作流
1. 发现通用语言
首先使用领域专家的术语识别领域概念:
行动项:
- 列出领域中的名词(实体、值对象)和动词(操作、事件)
- 为领域术语编写精确定义
- 找出同义词并消除歧义
- 询问:业务人员如何称呼它?这个概念的边界是什么?
输出格式: 创建一个词汇表章节,记录每个术语:
**Term** (Type: Entity/ValueObject/Event/Command)
- Definition: [Clear, domain-expert-approved definition]
- Examples: [Concrete examples]
- Invariants: [Rules that must always hold]
2. 分析现有领域模型
进行更改之前,先了解当前状态:
探索步骤:
- 确定当前在何处对领域概念进行建模(类型、架构、表)
- 梳理领域实体之间的关系
- 找出业务逻辑所在的位置(服务、函数、存储过程)
- 记录隐式规则和约束
- 注意命名或建模方面的不一致
需要回答的问题:
- 哪些类型/类表示领域概念?
- 不变式有哪些?在哪里强制执行?
- 哪些概念纠缠在一起,需要拆分?
- 是否存在不应存在的虚设类型或状态?
3. 识别不一致和异味
需要发现的常见问题:
命名不一致
- 同一概念使用不同名称(User、Account、Customer)
- 不同概念使用相同名称(作为实体的 Order 与作为命令的 Order)
- 技术名称渗入领域语言(DTO、DAO 后缀)
结构问题
- 可以表示非法状态(例如,
status: "approved" | "rejected",同时有独立的approved_at和rejected_at字段,且两者都可以设置) - 基本类型痴迷(用字符串表示电子邮件,用数字表示金额)
- 在某些状态下实际必需、却标记为可选的字段
- 用 null/undefined 表示多个不同状态
纠缠的关注点
- 领域逻辑与基础设施混杂(在业务逻辑中访问数据库)
- 一个类型/模块承担多项职责
- 时间耦合(必须先调用 A 再调用 B,否则系统会出错)
缺失的概念
- 对话中存在、代码中却没有的领域概念
- 应该显式表示的隐式状态
- 通过注释或约定而非类型来强制执行的业务规则
4. 设计领域模型
应用类型驱动和数据驱动原则:
数据建模:
- 从数据形状入手:事实是什么?
- 对不会变化的事实使用不可变值
- 显式建模状态转换
- 将标识与属性分开
- 思考:哪些内容会一起变化?哪些会独立变化?
类型设计:
- 为互斥状态创建和类型:
type PaymentStatus = | Pending | Approved { approvedAt: Timestamp, approvedBy: UserId } | Rejected { rejectedAt: Timestamp, reason: string } - 使用积类型确保所有必需数据都存在
- 为基本类型创建语义包装:
type EmailAddress = EmailAddress of string // with validation type Money = { amount: Decimal, currency: Currency } - 让不可能的状态无法表示
工作流建模:
- 将每个业务工作流建模为清晰的流水线:
ValidateInput → ExecuteBusinessLogic → HandleResult → Persist → Notify - 找出决策点并显式建模
- 将纯业务逻辑与副作用(IO、时间、随机性)分开
- 使用清晰的函数签名来说明意图
5. 构建并维护通用语言
一致性规则:
- 在代码、文档、对话和 UI 中使用完全相同的术语
- 领域语言变更时,更新所有表述
- 避免在领域代码中使用技术术语(除非属于领域术语,否则不要使用“factory”“manager”“handler”)
- 不要为了技术上的方便而改名领域概念
代码约定:
- 领域类型应与领域语言完全一致
- 函数名应使用领域动词
- 模块边界应遵循领域边界
- 注释应解释领域规则,而非实现细节
文档:
- 及时更新术语表
- 记录决策的原因(尤其是约束和不变量)
- 将代码链接到领域文档
- 明确说明隐式领域规则
6. 可视化领域模型
使用图表传达领域结构和关系:
使用 Mermaid 表示关系:
classDiagram
Order --> Customer
Order --> OrderLine
OrderLine --> Product
Order --> PaymentStatus
class Order {
+OrderId id
+CustomerId customerId
+List~OrderLine~ lines
+PaymentStatus status
}
class PaymentStatus {
<<enumeration>>
Pending
Approved
Rejected
}
使用 Mermaid 表示工作流:
graph LR
A[Receive Order] --> B{Valid?}
B -->|Yes| C[Calculate Total]
B -->|No| D[Return Validation Error]
C --> E[Process Payment]
E --> F{Payment Success?}
F -->|Yes| G[Fulfill Order]
F -->|No| H[Cancel Order]
使用 Mermaid 表示状态转换:
stateDiagram-v2
[*] --> Draft
Draft --> Submitted: submit()
Submitted --> Approved: approve()
Submitted --> Rejected: reject()
Approved --> Fulfilled: fulfill()
Fulfilled --> [*]
Rejected --> [*]
使用 Graphviz/DOT 表示复杂关系:
digraph domain {
rankdir=LR;
node [shape=box];
Customer -> Order [label="places"];
Order -> OrderLine [label="contains"];
OrderLine -> Product [label="references"];
Order -> Payment [label="requires"];
Payment -> PaymentMethod [label="uses"];
}
使用 ASCII 绘制快速草图:
Customer
└─> Order (1:N)
├─> OrderLine (1:N)
│ └─> Product
└─> Payment (1:1)
└─> PaymentMethod
何时使用:
- Mermaid classDiagram:实体关系和类型结构
- Mermaid graph/flowchart:业务工作流和决策树
- Mermaid stateDiagram:状态转换和生命周期
- Graphviz/DOT:复杂依赖图、模块边界
- ASCII:讨论期间的快速草图、简单层级结构
领域建模反模式
贫血领域模型
- 症状:数据结构只有 getter/setter,所有逻辑都放在单独的服务中
- 问题:增加了形式上的繁琐,却没有封装带来的好处,违背了以数据为中心的原则
- 解决方案:让数据保持为数据;将相关转换放在同一模块中,但与数据定义分开
实体服务反模式
- 症状:出现
UserService、OrderManager、ProductFactory这类类名 - 问题:掩盖了实际操作;缺乏通用语言
- 解决方案:根据领域操作命名函数:
approveOrder、cancelSubscription、calculateDiscount
原始类型偏执
- 症状:用字符串表示邮箱、用数字表示金额、用布尔标志表示状态
- 问题:没有类型安全;可以表示无效值
- 解决方案:创建带有验证的语义类型
意外复杂性
- 症状:使用复杂抽象和设计模式,却没有明确的领域收益
- 问题:增加了掩盖领域含义的层次
- 解决方案:简化设计;优先使用组合而非继承;避免过早抽象
隐藏的时间耦合
- 症状:必须按特定顺序调用方法,否则系统会出错
- 问题:将工作流与状态管理复杂地耦合在一起
- 解决方案:明确工作流;使用类型强制执行有效的状态转换
布尔盲视
- 症状:使用布尔标志表示状态(isApproved、isActive、isDeleted)
- 问题:多个布尔值可能表示不可能的状态
- 解决方案:对互斥状态使用和类型
结合现有模型进行考量
向现有领域模型中添加内容或对其进行更改时:
- 梳理当前状态:记录现有类型、关系和模式
- 识别受影响的概念:此更改会涉及哪些现有概念?
- 检查一致性:新设计是否遵循现有模式?如果不遵循,原因是什么?
- 评估影响:进行此更改会导致哪些内容出错?
- 迁移路径:如何从当前状态演进到目标状态?
- 更新通用语言:确保更新所有使用位置
- 可视化前后变化:创建展示当前模型和拟议模型的图表
关键问题:
- 此更改是否符合现有领域边界?
- 我们是否使用一致的术语?
- 这会引入新概念,还是复用现有概念?
- 我们是在修正不一致,还是引入新的不一致?
- 能否逐步完成此更改?
领域建模检查清单
完成领域建模工作前:
语言与沟通:
- 所有领域概念都使用通用语言命名
- 领域术语表已更新,包含新增或变更的术语
- 所有代码、文档和交流都使用统一的术语
- 已明确识别并记录限界上下文
类型设计:
- 类型使非法状态无法表示
- 不存在基本类型偏执;恰当使用语义类型
- 实体具有清晰的标识;值对象不可变
- 对互斥状态使用和类型
领域逻辑:
- 业务规则明确且可测试
- 数据与行为得到恰当分离
- 工作流建模为清晰的数据转换
- 领域逻辑是纯的(无副作用)
- 消除时间耦合,或将其明确表示
聚合与边界:
- 聚合边界明确
- 聚合能够维护自身的不变量
- 对聚合的外部引用仅使用 ID
- 聚合保持小巧且专注
- 事务边界设置恰当
一致性与集成:
- 已解决与现有模型之间的不一致,或将其记录在案
- 已定义跨聚合一致性策略(事务性或最终一致性)
- 使用领域事件记录重要事件
- 限界上下文之间通过防腐层集成
文档:
- 可视化图表清晰地传达设计
- 已记录关键决策和不变量
- 上下文映射展示了限界上下文之间的关系
资源
references/
此技能包含供深入学习的参考文档:
-
ddd_foundations_and_patterns.md:Eric Evans 的 DDD 基础概念(实体、值对象、聚合、限界上下文、仓储、领域事件)、Martin Fowler 关于通用语言的指导,以及实用的 Clojure/函数式模式。理解 DDD 构建模块及其应用方式的必读资料。
-
rich_hickey_principles.md:Rich Hickey 演讲中的核心概念,包括 Simple Made Easy、Value of Values 和 The Language of the System。重点介绍面向数据的设计、简洁性、解缠复杂性,以及不可变值的强大之处。
-
wlaschin_patterns.md:Scott Wlaschin 的类型驱动设计模式、领域建模方法、函数式架构指导和铁路导向编程。重点是让非法状态无法表示,并使用类型进行设计。
-
visualization_examples.md:Mermaid、Graphviz 和 ASCII 领域建模图表模式的综合示例。包含实体关系、工作流、状态机、聚合边界和限界上下文映射。
需要深入了解特定原则或模式时,请加载这些参考资料。