DDD 学习笔记:从领域原语到限界上下文
梳理 DDD 的领域原语、实体、统一语言、聚合根与限界上下文,并通过 TheGrandQuiz 的薄弱概念状态机展示这些概念如何落到真实 Python 代码。
1. 引言
DDD 并不是所有项目都必须套用的架构模板,也不等于分层目录或微服务。它真正擅长解决的,是业务规则复杂、术语容易冲突、模型需要长期演进的问题。学习 DDD 能帮助开发者先看清业务边界,再决定模块职责、协作契约和代码结构。
在 Vibe Coding 盛行的今天,这套方法反而更重要:AI 可以快速生成实现,但只有人先给出稳定的领域语言、边界与不变量,生成速度才不会转化成更快的架构腐化。可以先把 DDD 的工作过程概括为:
- 领域划分、明确边界、统一语言
- 限界上下文划分、上下文关系梳理
- 模块结构组织
- 建模
- 编码这不是一条必须严格照做的线性流程,但它提供了一组很实用的检查点。AI Coding Test 和 Matt Pocock 的工程化 skills 所强调的边界、契约与可验证性,也与 DDD、SOLID 的许多思想相通。掌握这些原则,至少能让我们在调用 AI 编码时先给出“什么必须保持正确”,而不只是描述“要生成什么页面或函数”。
DDD 通常分为两个观察层次:战略设计关注子域、限界上下文及其协作关系;战术设计关注实体、值对象、聚合、领域服务与仓储等模型构件。下文先从几个常用战术概念出发,再回到限界上下文,最后用 TheGrandQuiz 的真实业务规则串起完整示例。
2. Domain Primitive(领域原语)
2.1 定义
Domain Primitive(DP,领域原语)并不是 Eric Evans 原始 DDD 体系中的标准构件,而是国内工程实践中常见的一种细粒度建模方法。它可以理解为:具有明确业务含义、能够自我校验并封装相关行为的小型值对象。
它将 String、Long、BigDecimal 等基础类型转换成领域语言:
String → EmailAddress、AccountId
BigDecimal → Money、Percentage
两个 Date → DateRangeDP 本质上是对值对象(Value Object)更严格、更细粒度的运用,并非 DDD 中独立的标准构件。
2.2 核心价值
- 将隐含业务概念显式化;
- 将分散的校验和行为集中起来;
- 避免不同基础类型参数被误用;
- 保证领域对象创建后始终合法;
- 提升代码的业务表达力。
register(String email, String phone);改为:
register(EmailAddress email, PhoneNumber phone);方法签名本身就表达了业务含义。
2.3 主要特征
一个合格的 DP 通常具备:
- 语义明确:代表一个完整的领域概念;
- 创建即合法:在构造时完成校验;
- 不可变:修改操作返回新对象;
- 按值相等:没有独立身份;
- 包含行为:不仅保存数据,还封装相关业务规则。
2.4 在领域模型中的位置
基础类型
String / Long / BigDecimal
↓ 封装业务语义
领域原语 DP
EmailAddress / AccountId / Money / Percentage
↓ 组成
实体 Entity
Customer / Account / OrderLine
↓ 组成并维护整体一致性
聚合 Aggregate
Order / BankAccount
↓ 多个领域对象都不适合承担时
领域服务 Domain Service职责划分:
- DP 保证单个值自身合法;
- 实体维护自身状态和生命周期;
- 聚合维护多个对象之间的一致性;
- 领域服务处理不适合归属单个对象的业务规则。
例如:
- 转账金额必须大于零:
TransferAmount; - 金额运算必须币种一致:
Money; - 账户余额是否充足:
Account聚合; - 邮箱是否已经注册:需要查询仓储,不属于
EmailAddress。
2.5 DP、值对象与 DTO
- DP:细粒度、创建即合法、具有业务行为的值对象;
- 值对象:范围更广,例如
Address、DateRange; - DTO:只负责跨边界传输数据,可以暂时不合法,不应承载核心领域规则。
典型转换过程:
外部请求 DTO → 转换与校验 → DP → 实体或聚合2.6 适用信号
出现以下情况时,可以考虑提取 DP:
- 相同校验散落在多个地方;
- 多个参数类型相同,容易传错;
- 数据带有单位、币种、范围或格式;
- 某个值具有标准化或运算规则;
- 非法值不应进入领域层;
- 仅看基础类型无法理解业务含义。
2.7 注意事项
- 不要机械地包装所有基础类型;
- DP 不应直接访问数据库或外部服务;
- 规则必须符合当前限界上下文;
- 同一个值在不同场景中可能需要不同模型;
- 没有业务规则、行为或误用风险的包装类,通常没有必要。
2.8 总结
DP 是领域模型最底层的语义砖块:把隐含概念变成显式类型,把散落校验变成内聚规则,把技术参数变成领域语言。
3. Entity(实体)
3.1 定义
实体是具有唯一标识和生命周期的领域对象。
判断两个实体是否相同,主要看身份标识,而不是属性是否完全一致:
用户改了姓名,仍然是同一个用户;
订单修改了地址,仍然是同一个订单。3.2 核心特征
- 具有唯一标识:如
OrderId、UserId; - 具有生命周期:会经历创建、变化和消亡;
- 通过身份判断相等:属性变化不影响身份;
- 封装领域行为:由实体自己改变状态;
- 维护自身不变性:任何操作后都应处于合法状态。
3.3 实体不是数据表映射
贫血实体只保存数据:
order.setStatus(PAID);领域实体应表达业务行为:
order.pay(payment);
order.cancel(reason);
order.ship(trackingNumber);状态变化由实体控制,外部不能随意修改:
class Order {
private OrderStatus status;
public void cancel() {
if (status == SHIPPED) {
throw new DomainException("已发货订单不能取消");
}
status = CANCELLED;
}
}3.4 Entity 与 DP / Value Object
| Entity | DP / Value Object |
|---|---|
| 具有唯一身份 | 没有独立身份 |
| 关注生命周期 | 关注值本身 |
| 属性可以变化 | 通常不可变 |
| 按 ID 判断相等 | 按全部属性判断相等 |
例如:
Order → Entity
OrderId → DP
ShippingAddress → Value Object
Money → DP / Value Object3.5 建模原则
- 创建时必须满足必要条件;
- 避免公开无约束的 Setter;
- 用业务方法表达状态变化;
- 将属于实体自身的规则放回实体;
- 不把数据库字段机械地全部映射为领域属性;
- 实体只维护自身或所属聚合范围内的一致性。
3.6 总结
Entity 表达“它是谁,以及它在生命周期中能够做什么”,而不只是保存一组数据。
4. Ubiquitous Language(统一语言)
4.1 定义
统一语言是业务人员和开发人员在特定限界上下文中共同使用的一套语言。
它应同时出现在:
- 业务沟通;
- 需求文档;
- 领域模型;
- 类名与方法名;
- 测试用例;
- 事件和接口定义中。
4.2 核心价值
- 消除业务与技术之间的翻译损耗;
- 让代码直接表达业务含义;
- 暴露概念冲突和认知差异;
- 保证团队对同一术语具有一致理解;
- 推动领域模型持续演进。
例如,不使用模糊的技术语言:
order.updateStatus(3);而使用领域语言:
order.cancel(reason);
order.confirmPayment(payment);
order.ship(trackingNumber);4.3 统一语言不是统一词典
统一语言不要求整个系统对同一词语只有一个定义,而是要求:
在同一个限界上下文内,术语含义必须一致。
例如“客户”在不同上下文中可能不同:
销售上下文:客户是销售线索和跟进对象
订单上下文:客户是下单人
物流上下文:客户是收货相关方
财务上下文:客户是付款与开票主体如果含义不同,应允许它们拥有不同模型。
4.4 如何形成统一语言
- 与领域专家共同梳理业务过程;
- 记录关键名词、动词和业务规则;
- 发现同义词时统一命名;
- 发现一词多义时划分上下文;
- 优先使用业务行为命名方法;
- 在代码和文档中持续修正模型。
重点不仅是名词,也包括动词和规则:
名词:订单、付款、发货单
动词:下单、支付、取消、发货
规则:已发货订单不能取消4.5 常见问题
- 使用
process()、handle()等模糊命名; - 代码术语与业务术语不一致;
- 将数据库表名直接当作领域语言;
- 不同团队使用同一术语表达不同含义;
- 术语只存在于文档,没有进入代码;
- 将统一语言理解为一次性确定的静态词典。
4.6 总结
统一语言是业务模型与代码之间的桥梁。代码应尽可能读起来像对业务规则的直接描述。
5. Aggregate Root 与 Bounded Context(聚合根与限界上下文)
5.1 Aggregate(聚合)
定义
聚合是一组需要共同维护业务一致性的实体和值对象。
聚合内部具有明确边界,对外只暴露一个入口——聚合根。
Order 聚合
├── Order ← 聚合根
├── OrderLine ← 内部实体
├── ShippingAddress ← 值对象
└── Money ← DP / 值对象聚合回答的问题是:
哪些对象必须在同一次业务操作中保持一致?
聚合根
聚合根是聚合中唯一允许外部直接引用和操作的实体。
外部代码应当:
order.addProduct(productId, quantity, price);
order.changeAddress(address);
order.cancel(reason);而不是绕过订单直接修改内部对象:
orderLine.setQuantity(quantity);聚合根负责:
- 控制对内部对象的访问;
- 执行业务操作;
- 维护聚合整体不变性;
- 管理内部实体的生命周期;
- 作为仓储加载和保存的基本单位。
聚合设计原则
- 一个事务通常只修改一个聚合;
- 聚合内部保持强一致性;
- 聚合之间通过 ID 引用;
- 跨聚合协作通过应用服务、领域服务或领域事件完成;
- 聚合应尽量小,避免加载庞大对象图;
- 仓储通常围绕聚合根设计。
class Order {
private CustomerId customerId;
}通常优于:
class Order {
private Customer customer;
}前者明确了 Order 和 Customer 属于不同聚合。
5.2 Bounded Context(限界上下文)
定义
限界上下文是某套领域模型和统一语言能够保持一致的边界。需要区分的是:子域属于问题空间,限界上下文属于解决方案空间。二者经常对应,但并不要求严格一一映射。
在边界内部:
- 术语含义明确;
- 模型结构统一;
- 业务规则一致。
在边界外部,相同词语可以拥有不同含义和模型。
电商系统
├── 商品上下文
│ └── Product:描述、类目、售价
├── 订单上下文
│ └── Product:商品快照
├── 库存上下文
│ └── Product:库存单位
└── 物流上下文
└── Product:待运输物品它们都叫 Product,但关注的信息和行为不同,不应强行共用一个“大而全”的模型。
核心价值
- 防止一个模型承担整个系统的全部含义;
- 隔离不同业务规则和变化方向;
- 明确团队和系统之间的协作边界;
- 降低模型耦合;
- 为服务拆分提供业务依据。
上下文之间如何协作
限界上下文之间应通过明确契约交互:
- API;
- 消息或领域事件;
- DTO;
- 防腐层;
- 上下文映射。
一个上下文不应直接复用另一个上下文的内部领域对象,而应完成模型转换:
订单上下文的 OrderPaid
↓ 事件 / DTO
物流上下文转换
↓
Shipment5.3 两者的关系
聚合根和限界上下文属于不同层级:
限界上下文
└── 领域模型
├── 聚合 A
│ └── 聚合根
├── 聚合 B
│ └── 聚合根
└── 领域服务| 概念 | 解决的问题 |
|---|---|
| 限界上下文 | 一套模型在什么范围内有效 |
| 聚合 | 哪些对象需要共同保持一致 |
| 聚合根 | 外部通过哪个入口操作聚合 |
可以简单记为:
限界上下文划分模型边界;
聚合划分一致性边界;
聚合根控制聚合访问边界。常见误区
- 把整个限界上下文设计成一个巨大聚合;
- 把每张数据表都视为一个聚合;
- 聚合之间直接持有完整对象;
- 绕过聚合根修改内部实体;
- 为了强一致性把大量对象塞进同一事务;
- 将限界上下文简单等同于微服务。
总结
限界上下文确定模型适用的范围,聚合确定业务一致性的范围,聚合根则是维护该一致性的唯一入口。
6. 真实案例:TheGrandQuiz 的薄弱概念状态机
前面的概念只有进入真实业务规则,才会变成有约束力的领域模型。下面的案例来自我的项目 TheGrandQuiz:它会在考核后记录学习者的薄弱概念,并在后续优先复考。
这里的核心业务语言是:
- KnowledgeItem(知识点): 当前可以被考核的最小知识单元;
- Verdict(判决): 一次作答的结果,只能是“对”“勉强”或“错”;
- 薄弱: 当前尚未掌握,需要优先复考;
- 观察中: 已经答对一次,但还需要再次确认;
- 销账: 连续答对两次后移出薄弱台账,它不是一个需要持久化的状态。
对应的不变量非常明确:
未追踪 + 对 → 仍不追踪
任意状态 + 错/勉强 → 薄弱,连续答对次数归零
薄弱 + 对 → 观察中
观察中 + 对 → 销账,从台账移除6.1 用领域原语和不可变对象表达规则
下面是从生产代码中提炼出的可运行版本。它没有依赖数据库或 Web 框架,只表达领域规则:
from dataclasses import dataclass
from enum import StrEnum
class Verdict(StrEnum):
CORRECT = "对"
PARTIAL = "勉强"
WRONG = "错"
class ConceptState(StrEnum):
WEAK = "薄弱"
OBSERVING = "观察中"
@dataclass(frozen=True)
class KnowledgeItemId:
value: str
def __post_init__(self) -> None:
if not self.value.strip():
raise ValueError("KnowledgeItemId 不能为空")
@dataclass(frozen=True)
class ConceptRecord:
item_id: KnowledgeItemId
state: ConceptState
consecutive_correct: int
def __post_init__(self) -> None:
expected = 0 if self.state is ConceptState.WEAK else 1
if self.consecutive_correct != expected:
raise ValueError("状态与连续答对次数不一致")
def apply_verdict(
record: ConceptRecord | None,
verdict: Verdict,
*,
item_id: KnowledgeItemId,
) -> ConceptRecord | None:
if verdict in {Verdict.PARTIAL, Verdict.WRONG}:
return ConceptRecord(
item_id=item_id,
state=ConceptState.WEAK,
consecutive_correct=0,
)
if record is None:
return None
if record.state is ConceptState.OBSERVING:
return None
return ConceptRecord(
item_id=item_id,
state=ConceptState.OBSERVING,
consecutive_correct=1,
)这个模型里有几个刻意的选择:
KnowledgeItemId不再是可以随意传错的裸字符串,而是创建即合法的 DP;ConceptRecord是不可变值对象,非法的状态组合无法被构造;- 状态只能通过
apply_verdict()演化,外部没有 Setter; None同时表达“未追踪”或“已销账”,因为销账后的概念不应继续占据活动台账;- 函数不读取数据库、不调用 LLM,也不依赖当前时间,因此可以被确定性测试和回放。
6.2 聚合根负责一致性,仓储只负责持久化
领域规则之外,还需要一个有身份的入口控制状态修改。为了清楚展示聚合边界,下面把单个知识点的学习进度建模为 ConceptProgress 聚合根,并使用 KnowledgeItemId 作为身份:
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ConceptStateChanged:
item_id: KnowledgeItemId
before: ConceptState | None
after: ConceptState | None
class ConceptProgress:
def __init__(
self,
item_id: KnowledgeItemId,
record: ConceptRecord | None = None,
) -> None:
self.item_id = item_id
self._record = record
def record_verdict(
self,
verdict: Verdict,
) -> ConceptStateChanged:
before = self._record
self._record = apply_verdict(
before,
verdict,
item_id=self.item_id,
)
return ConceptStateChanged(
item_id=self.item_id,
before=before.state if before else None,
after=self._record.state if self._record else None,
)
@property
def state(self) -> ConceptState | None:
return self._record.state if self._record else None
class ConceptProgressRepository(Protocol):
def load(self, item_id: KnowledgeItemId) -> ConceptProgress: ...
def save(self, progress: ConceptProgress) -> None: ...
class EventPublisher(Protocol):
def publish(self, event: ConceptStateChanged) -> None: ...
def record_assessment_result(
*,
item_id: KnowledgeItemId,
verdict: Verdict,
repository: ConceptProgressRepository,
events: EventPublisher,
) -> None:
progress = repository.load(item_id)
event = progress.record_verdict(verdict)
repository.save(progress)
events.publish(event)这里的职责边界分别是:
- 领域层: 决定判决如何改变薄弱状态;
- 应用层: 编排加载、调用领域行为、保存和发布事件;
- 基础设施层: 决定仓储使用内存、SQLite 还是其他数据库;
- 接口层: 把 CLI、HTTP 或 Agent tool 的输入转换成应用命令。
因此,“LLM 判卷,代码记账”不是一句提示词,而是一条架构边界:模型可以参与语义判断,但只有确定性领域代码能够修改学习状态。
TheGrandQuiz 当前是本机单用户产品,生产实现为了简单和高效,使用 LearningMemory 台账集中保存这些记录。上面的教学版本则把每个 ConceptProgress 作为独立聚合;如果未来扩展到多用户,聚合身份还需要同时包含 LearnerId。模型边界应当服从真实的一致性与规模需求,而不是为了出现 Aggregate Root 这个名词强行包装。
6.3 用测试固定业务语言
DDD 中的测试不只是验证实现细节,也是在固定团队对业务规则的共同理解:
def test_wrong_then_two_correct_answers_discharge_memory() -> None:
item_id = KnowledgeItemId("ddd.aggregate")
progress = ConceptProgress(item_id)
progress.record_verdict(Verdict.WRONG)
assert progress.state is ConceptState.WEAK
progress.record_verdict(Verdict.CORRECT)
assert progress.state is ConceptState.OBSERVING
progress.record_verdict(Verdict.CORRECT)
assert progress.state is None
def test_wrong_answer_resets_observing_concept() -> None:
item_id = KnowledgeItemId("ddd.bounded-context")
progress = ConceptProgress(item_id)
progress.record_verdict(Verdict.WRONG)
progress.record_verdict(Verdict.CORRECT)
progress.record_verdict(Verdict.PARTIAL)
assert progress.state is ConceptState.WEAK这段代码的价值不在于用了多少 DDD 名词,而在于业务规则已经形成闭环:
统一语言
→ KnowledgeItem / 薄弱 / 观察中 / 销账
领域原语
→ KnowledgeItemId
值对象与不变量
→ ConceptRecord
聚合根
→ ConceptProgress
跨边界契约
→ ConceptProgressRepository / EventPublisher
可执行规则
→ 状态机测试真实项目中的实现还包含 SQLite 持久化、判决历史、事件追踪与 Eval,但这些能力都围绕同一个领域规则扩展,而不是反过来让数据库表或框架决定模型。完整实现可以查看 TheGrandQuiz 的 Learning Memory 源码。
结语
DDD 最重要的价值不是让代码里出现更多 Entity、Repository 或 Aggregate,而是让团队能够持续回答三个问题:
- 当前讨论的是哪个业务边界?
- 这个边界里有哪些必须始终成立的不变量?
- 哪个对象有权改变这些状态?
当这些答案能够同时出现在讨论、文档、代码和测试中,DDD 才真正发挥作用。对 AI Coding 也是如此:模型可以帮助我们更快地产生实现,但领域语言、边界和不变量仍然需要由人负责定义与校验。