模块一-全景认知 | 第06讲:DDD 领域驱动设计核心 - 限界上下文、聚合根与领域事件
开场:没有边界的 AI,只会生成“看似正确”的代码
领域驱动设计(DDD)常被误解为“名词很多、表格很复杂”。但在 AI 架构师的视角里,DDD 的首要价值不是画漂亮的模型图,而是 为不确定性建立语义边界:限界上下文(Bounded Context) 让“同一个词在不同地方有不同含义”被显式管理;聚合(Aggregate) 让一致性边界清晰;领域事件(Domain Event) 让跨上下文协作可审计、可回放。
CodeSentinel 是一个典型 多上下文系统:代码审核、规则治理、代码仓连接、报告生成,各自有不同的不变量与生命周期。如果你把这些混在一个“大模型会话”里解决,模型很容易 串台——把“规则版本”当成“报告版本”,把“PR 评论”当成“架构违例”。这不是模型笨,而是 上下文没有被编码进系统结构。
本讲将完成三件事:建立 CodeSentinel 的 通用语言(Ubiquitous Language);划分 四个限界上下文 并给出 上下文映射;用 dataclasses 实现 聚合根、值对象、领域事件 的最小完整模型,并演示 事件分发模式。这些内容会直接喂给后续模块的用例服务与 LangChain 工具链:工具函数的输入输出,应尽量与领域模型对齐,而不是随手 dict。
把开场写厚一层,是为了强调 DDD 在 AI 项目里的“反直觉收益”:很多人以为 DDD 会增加样板代码,拖慢迭代;但在人机协作场景里,DDD 反而能降低返工,因为它把 语义与约束前置 了。模型最擅长在模糊需求下“补全”,而领域模型用类型与不变量告诉它“哪些补全合法”。当你后面为 CodeSentinel 写工具函数(Tool)时,你会反复体会到:没有通用语言,工具参数就会退化成一堆 str;有了通用语言,工具参数会变成 ReviewRequestId、ChangeSetRef 这类“自带说明书的类型”。
全局视角:限界上下文与协作关系
第一张图展示 CodeSentinel 的四个核心上下文及其职责边界(教学版命名,可与团队词典微调,但必须全员一致)。
flowchart TB
subgraph RC["ReviewContext 审核上下文"]
R1["ReviewRequest"]
R2["ReviewComment"]
R3["ReviewScore"]
end
subgraph RuC["RuleContext 规则上下文"]
U1["RulePack"]
U2["RuleVersion"]
U3["Violation"]
end
subgraph RepoC["RepositoryContext 仓库上下文"]
G1["RepositoryRef"]
G2["ChangeSet"]
end
subgraph RepC["ReportContext 报告上下文"]
P1["Report"]
P2["Finding"]
end
RC -->|发布事件| RuC
RC -->|引用| RepoC
RuC -->|输出违例| RepC
RC -->|汇总| RepC
第二张图用 上下文映射(Context Map) 表达团队关系(Partnership / ACL / OHS 等标记为后续演进预留)。
flowchart LR
ReviewContext["ReviewContext"]
RuleContext["RuleContext"]
RepositoryContext["RepositoryContext"]
ReportContext["ReportContext"]
ReviewContext -->|Customer-Supplier| RuleContext
ReviewContext -->|Conformist| RepositoryContext
RuleContext -->|Published Language| ReportContext
ReviewContext -->|Anti-Corruption Layer 可选| ReportContext
核心原理:从概念到代码的翻译规则
1. 为什么 AI 架构师必须重视 DDD?
大模型擅长模式补全,但 无法自动拥有你们组织的语义约束。DDD 把语义写进类型与不变量:
- 值对象 让非法状态难以构造(例如
Severity不可能是任意字符串)。 - 聚合根 明确一致性边界:外部只能通过根修改内部实体。
- 领域事件 让“发生了什么”成为一等公民,便于审计、异步处理与回放。
当你把 LangChain Tool 设计成 run_review(request_id: str) 这种含糊接口时,模型会乱用;当你把工具签名对齐 ReviewRequestId 与明确的 ReviewCommand,成功率会显著提升——这不是魔法,是 类型即提示(types as prompts)。
2. 通用语言(Ubiquitous Language)示例
| 术语 | 含义 | 禁止的模糊说法 |
|---|---|---|
| 审核请求 | 针对某次变更集合触发的一次审核流程 | “任务/job”混用 |
| 规则包 | 可版本化的一组静态/动态规则集合 | “配置”泛化 |
| 违例 | 规则执行后对某代码位置的否定判定 | “问题/issue”混用 |
| 报告 | 一次可交付的审核结果汇总物 | “日志” |
3. 聚合与聚合根
以 ReviewRequest 为根:ReviewComment 与 ReviewScore 的生命周期依附于审核请求。外部不应直接“新增评论”而不经过根的规则校验(教学代码里用方法表达此意图)。
4. 领域事件与最终一致性
ReviewCompleted 发布后,报告上下文可以异步生成 PDF/Markdown;规则上下文可以触发额外扫描。事件不是“随便抛 dict”,而是 显式类型,便于版本演进与序列化。
5. 事件分发模式
本讲采用 进程内同步分发(简单、可测试):DomainEvent 列表挂在聚合操作上,由 EventBus 统一派发。后续模块可替换为消息队列,不改变领域核心。
6. 限界上下文划分的实操标准:不是越多越好
划分上下文时,可以用三个问题自测:第一,是否存在一套 独立演化 的模型(例如规则包版本与报告模板版本不应强行绑死);第二,是否存在不同的 一致性策略(审核流程强一致,报告生成可最终一致);第三,是否存在不同的 团队边界(即使小团队也要为未来拆分留余地)。CodeSentinel 的四个上下文在教学上清晰,但在真实公司中,你可能一开始合并为两个上下文,再随着规模拆分——这没问题,关键是 每次合并/拆分都要更新上下文映射与集成方式,否则集成就只能靠口头默契。
7. 聚合设计中的“大小”权衡:小聚合更安全
聚合越大,事务边界越大,并发冲突越多。对审核系统而言,把“评论流”“评分”“附件扫描结果”全部塞进一个超大聚合,可能导致高频写入互相锁死。更稳妥的路线通常是:以 ReviewRequest 为核心聚合,其他数据若生命周期独立且一致性要求弱,考虑提升为独立聚合并通过事件协同。教学模型为了可读性会放在同一聚合内,但你在架构评审时要问:这是否会在高并发 PR 审核场景成为瓶颈。
8. 值对象与实体的选择:不要为“没有标识”硬造标识
CodeLocation 适合值对象:它由字段组合定义身份,不追求长期追踪。ReviewComment 在教学里给了 comment_id,更像实体行为;若评论完全依附于审核且不需要跨聚合引用,也可以让评论成为聚合内实体但不对外暴露全局 ID。DDD 没有唯一标准答案,但必须 自洽:对外 API 暴露的标识体系要与模型一致,否则前端、Webhook、审计系统会对不上。
9. 领域事件的命名:用过去时表达事实
ReviewCompleted 比 CompleteReview 更适合事件名,因为事件是“已发生”。命令层可以用 CompleteReview,事件层用过去时,能在日志与消息队列里显著降低误读。对 AI 生成代码而言,命名规范是廉价但高杠杆的约束:让模型按规范生成,比事后纠正成本低一个数量级。
10. 防腐层(ACL)在什么时候值得引入
当外部系统(例如 Git 平台的 PR 模型)与内部领域模型差异很大时,直接在用例里拼装字典会把外部概念泄漏进来。ACL 的目标是把外部模型翻译为内部模型,让领域保持干净。CodeSentinel 后续在对接不同 Git 供应商时,ACL 往往会出现在 RepositoryContext 与 ReviewContext 的边界上。你现在不需要实现它,但要在脑子里预留位置:集成复杂度会推动 ACL 出现,而不是“为了模式而模式”。
11. 战略设计与战术设计:本讲聚焦战术,但不要丢失战略
DDD 常分战略(上下文划分、协作关系)与战术(聚合、实体、值对象、领域服务)。CodeSentinel 的战略结论来自业务拆分与风险拆分:审核、规则、仓库、报告必须能独立演化。战术设计把战略落地为可执行代码。若你只学战术不学战略,最容易出现“模型很漂亮但上下文串台”;若只画战略不写战术,则会出现“PPT 正确、代码全错”。本讲代码属于战术样板,请始终把它挂回前面的上下文图理解。
12. 领域模型的可测试性:不变量必须可触发
每个 __post_init__ 或聚合方法里的校验,都应有对应测试用例触发失败路径。否则不变量只是“写在纸上的规则”。AI 生成领域代码时常常漏掉负例测试,架构师要在 PR 模板里强制要求:每个新不变量至少一条失败测试。这会把 DDD 从“文档活动”变成“工程活动”。
13. 与持久化映射的边界:领域对象不要为表结构妥协
你会遇到“数据库里评论是另一张表”的情况,领域仍可以保持聚合内列表;映射层负责拆行与组装。不要在领域对象里加入 db_row_id 这类纯粹持久化字段,除非你明确这是技术主键且不会污染业务决策。若分不清楚,先坚持领域纯净,再在基础设施写 mapper。
代码实战:完整领域模型(dataclasses + 校验 + 事件)
下列代码为 教学用紧凑实现:放在一个模块里便于阅读;真实项目应拆到
domain/review/等子包,并补充更严格的 ID 生成与持久化映射。
代码走读:逐段理解“聚合根如何守住边界”
导入与基础类型:Enum 用于 ReviewStatus 与 Severity,让非法枚举值在构造期失败;这比在业务里到处 if x not in (...) 更干净。uuid4 生成标识符是教学默认,生产可换 ULID/UUID7 以满足排序与索引友好。utcnow 单独函数是为了可测试:未来你可以在测试里 monkeypatch 时间,而不改领域代码。
值对象 CodeLocation:frozen=True 强调不可变;__post_init__ 把坐标系规则写死(行号从 1 开始、区间合法)。这对应通用语言里“代码位置”的定义:不是任意三元组字符串,而是受约束结构。对 CodeSentinel,后续很多规则违例都会携带 location,值对象统一能减少重复校验。
实体 ReviewComment 与 ReviewScore:评论允许 location 为空,表示整文件级别意见;评分三分量约束在 0..100,体现“评分卡”业务。若你未来要支持不同评分维度,优先扩展值对象字段并同步更新通用语言,而不是偷偷加 dict。
事件类型层次:DomainEvent 基类携带 occurred_at,保证每条事件都有时间线。具体事件只增加必要字段,避免“万能 payload”。ArchitectureViolationDetected 同时携带 rule_code 与 location,为报告上下文提供可聚合维度。事件字段越清晰,后续做数据仓库与指标越省事。
聚合根 ReviewRequest:对外方法表达业务动作:create 工厂、start、add_comment、mark_violation、complete。内部 _events 列表与 pull_events 是典型模式:避免事件重复发布。注意 mark_violation 与 add_comment 的差异:前者强调架构规则信号,后者是一般评论;在报表上可能走不同漏斗。
EventBus 与 handler:教学实现用 isinstance 匹配事件类型,简单直观;生产可改为注册表 + 多处理器链,并加入异步与重试策略(但要小心与事务一致性冲突)。进程内总线适合单元测试与本地开发;消息队列适合跨服务。请记住:领域只产生事件,不决定如何投递。
demo_flow:演示最小串联,帮助你确认 import 与类型无误。把它当成“可运行的领域说明书”。
把走读与代码对照阅读后,你应该能独立回答:哪些字段必须是值对象?哪些操作必须通过聚合根?哪些事实应记录为事件?答得上,战术 DDD 才算入门。
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Callable, Iterable, Protocol, runtime_checkable
from uuid import uuid4
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class ReviewStatus(str, Enum):
CREATED = "created"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class Severity(str, Enum):
INFO = "info"
WARN = "warn"
ERROR = "error"
BLOCKER = "blocker"
@dataclass(frozen=True)
class CodeLocation:
path: str
start_line: int
end_line: int
def __post_init__(self) -> None:
if not self.path.strip():
raise ValueError("path 不能为空")
if self.start_line < 1 or self.end_line < 1:
raise ValueError("行号必须从 1 开始")
if self.end_line < self.start_line:
raise ValueError("end_line 不能小于 start_line")
@dataclass(frozen=True)
class ReviewRequestId:
value: str
@staticmethod
def new() -> ReviewRequestId:
return ReviewRequestId(value=str(uuid4()))
@dataclass
class ReviewComment:
comment_id: str
body: str
location: CodeLocation | None
severity: Severity
def __post_init__(self) -> None:
if not self.body.strip():
raise ValueError("comment body 不能为空")
@dataclass
class ReviewScore:
overall: float
security: float
maintainability: float
def __post_init__(self) -> None:
for name, val in {
"overall": self.overall,
"security": self.security,
"maintainability": self.maintainability,
}.items():
if not 0.0 <= val <= 100.0:
raise ValueError(f"{name} 必须在 0..100 之间")
@dataclass(frozen=True)
class DomainEvent:
occurred_at: datetime = field(default_factory=utcnow)
@dataclass(frozen=True)
class ReviewRequestCreated(DomainEvent):
review_id: ReviewRequestId
repository: str
change_set: str
@dataclass(frozen=True)
class ReviewCompleted(DomainEvent):
review_id: ReviewRequestId
comments: int
@dataclass(frozen=True)
class ArchitectureViolationDetected(DomainEvent):
review_id: ReviewRequestId
location: CodeLocation
severity: Severity
rule_code: str
@dataclass
class ReviewRequest:
"""审核聚合根:外部只能通过它的方法改变状态。"""
id: ReviewRequestId
repository: str
change_set: str
status: ReviewStatus
comments: list[ReviewComment] = field(default_factory=list)
score: ReviewScore | None = None
_events: list[DomainEvent] = field(default_factory=list, repr=False)
@staticmethod
def create(repository: str, change_set: str) -> ReviewRequest:
if not repository.strip():
raise ValueError("repository 不能为空")
if not change_set.strip():
raise ValueError("change_set 不能为空")
rid = ReviewRequestId.new()
rr = ReviewRequest(
id=rid,
repository=repository.strip(),
change_set=change_set.strip(),
status=ReviewStatus.CREATED,
)
rr._record(ReviewRequestCreated(review_id=rid, repository=rr.repository, change_set=rr.change_set))
return rr
def start(self) -> None:
if self.status != ReviewStatus.CREATED:
raise ValueError("只有 created 才能 start")
self.status = ReviewStatus.RUNNING
def add_comment(self, body: str, severity: Severity, location: CodeLocation | None = None) -> ReviewComment:
if self.status not in {ReviewStatus.RUNNING, ReviewStatus.CREATED}:
raise ValueError("当前状态不允许添加评论")
c = ReviewComment(comment_id=str(uuid4()), body=body, location=location, severity=severity)
self.comments.append(c)
return c
def mark_violation(self, location: CodeLocation, severity: Severity, rule_code: str) -> None:
if self.status != ReviewStatus.RUNNING:
raise ValueError("运行中才能标记架构违例")
if not rule_code.strip():
raise ValueError("rule_code 不能为空")
self._record(
ArchitectureViolationDetected(
review_id=self.id,
location=location,
severity=severity,
rule_code=rule_code.strip(),
)
)
def complete(self, score: ReviewScore) -> None:
if self.status != ReviewStatus.RUNNING:
raise ValueError("只有 running 才能完成")
self.score = score
self.status = ReviewStatus.COMPLETED
self._record(ReviewCompleted(review_id=self.id, comments=len(self.comments)))
def pull_events(self) -> list[DomainEvent]:
"""取出待发布事件并清空(避免重复发布)。"""
out = list(self._events)
self._events.clear()
return out
def _record(self, event: DomainEvent) -> None:
self._events.append(event)
@runtime_checkable
class DomainEventHandler(Protocol):
def __call__(self, event: DomainEvent) -> None: ...
class EventBus:
def __init__(self) -> None:
self._handlers: dict[type[DomainEvent], list[DomainEventHandler]] = {}
def subscribe(self, event_type: type[DomainEvent], handler: DomainEventHandler) -> None:
self._handlers.setdefault(event_type, []).append(handler)
def publish(self, events: Iterable[DomainEvent]) -> None:
for event in events:
for et, handlers in list(self._handlers.items()):
if isinstance(event, et):
for h in handlers:
h(event)
def demo_flow() -> None:
bus = EventBus()
bus.subscribe(ReviewRequestCreated, lambda e: print("EVT created", e.review_id.value))
bus.subscribe(ReviewCompleted, lambda e: print("EVT completed", e.review_id.value, e.comments))
rr = ReviewRequest.create(repository="org/app", change_set="pr-1024")
bus.publish(rr.pull_events())
rr.start()
rr.add_comment("命名空间清晰", Severity.INFO, CodeLocation("src/api.py", 1, 40))
rr.mark_violation(CodeLocation("src/api.py", 10, 12), Severity.ERROR, "LAYERING-001")
rr.complete(
ReviewScore(overall=82.5, security=88.0, maintainability=79.0),
)
bus.publish(rr.pull_events())
if __name__ == "__main__":
demo_flow()
继续走读(状态机与业务语言对齐):ReviewStatus 的迁移路径在本讲示例里是 created → running → completed,并保留 failed 作为异常完成态(后续讲次会用到)。这意味着你的通用语言里必须定义:什么算“完成”,什么算“失败”,失败是否可重试。不要把状态机隐藏在 bool 或 str 里;状态机是审核域的核心结构之一,值得显式建模与测试。AI 生成代码时常常用 status: str 凑合,你要把它打回去改成枚举或受控集合。
继续走读(评论 vs 违例:为什么要两条路径):add_comment 产生 ReviewComment 实体,适合展示给开发者阅读;mark_violation 记录 ArchitectureViolationDetected 事件,更适合进入治理看板与趋势分析。两者在真实系统可能合并,也可能拆分;关键是团队在词典里解释清楚“评论是否可能升级为违例”“违例是否必须附带修复建议”。没有词典,模型会把它们混写成同一种结构。
继续走读(评分作为值对象的边界):ReviewScore 放在 complete 时一次性写入,避免运行中半成品的分数被外部读取。若你需要“运行中实时评分”,要么引入新的显式状态,要么拆分为独立聚合,由事件同步。不要一边 running 一边允许分数突变却不记录事件,否则审计无法解释“分数为何变化”。
继续走读(事件总线的责任边界):EventBus.publish 不应捕获所有异常并吞掉;那会让处理器失败静默。生产更常见的是:记录失败、重试、死信队列。教学代码用 print 仅示意。你要记住:订阅者失败是否应回滚聚合事务 是架构决策,不是实现细节。很多时候领域事务已提交,事件处理失败要走补偿。
继续走读(与 LangChain 工具的参数映射):把 ReviewRequestId、ChangeSetRef、Severity 映射为工具参数类型时,优先使用 JSON Schema 能表达的枚举与结构,避免把 CodeLocation 拆成四个松散参数导致模型漏传。工具描述里引用通用语言词汇(中文或英文统一一种),减少“同名异义”。
继续走读(性能与聚合加载):当评论数量极大时,一次性加载聚合可能过重。此时常见方案是:聚合根保存计数与摘要,评论详情走读模型分页查询;或把评论拆为独立聚合,通过 review_id 关联。无论哪种,都属于战略性调整,应通过 ADR 记录,而不是悄悄改代码。
继续走读(安全与隐私):评论文本可能包含代码片段,代码片段可能包含密钥泄露风险。领域层可以定义“提交前必须脱敏”的规则,但具体脱敏实现可放在基础设施(检测密钥模式)。不要把正则堆进实体方法里导致难以测试;用端口 SecretRedactor 更清晰。
继续走读(国际化):若 CodeSentinel 面向多语言团队,通用语言可以双语,但事件与内部模型字段建议固定一种“canonical language”,UI 翻译放在表现层。不要把多语言字符串混进领域事件 payload,否则报表聚合会痛苦。
继续走读(如何从本模块迁移到仓储):下一步你会把 ReviewRequest 持久化。记住:仓储接口应以聚合根为粒度 save/load,不要暴露“更新某条评论”这种穿透聚合的方法,除非你已经明确放弃该一致性边界。
与四个上下文的落位建议
ReviewRequest*属于 ReviewContext 的核心聚合。ArchitectureViolationDetected是跨上下文信号:规则上下文可消费它来更新统计;报告上下文可聚合展示。CodeLocation、Severity是 值对象:尽量在多个上下文复用同一实现,或通过 Published Language 共享 DTO(后续讲次)。
补充说明(为什么要写这几条落位):落位建议的价值在于防止“同一个类承担多个上下文的语义”。当你发现 ReviewRequest 开始直接引用 RulePack 实体,或 Report 开始直接修改 ReviewComment,就说明上下文边界被击穿,需要重构集成方式(事件、ACL、或明确的应用服务编排)。本讲刻意保持教学模型简单,但你要用这几条当红线。
补充说明(教学代码的运行方式):将整段 Python 保存为 domain_demo.py 后执行 python domain_demo.py,应能看到事件打印。若你把它拆进包结构,请把 demo_flow 改成测试用例,避免 print 成为唯一验证手段。
补充说明(与 CodeSentinel 后续持久化的对齐点):当你引入 ORM 模型时,请保持聚合根仍是领域对象:ORM 实体只在基础设施层出现,并通过 mapper 与领域互转。不要在 ORM 实体上直接写 mark_violation 这类业务方法,否则很快会出现“数据库注解驱动业务”的倒置结构。把这条当作 CodeSentinel 持久化层的红线,评审时一眼就能判定是否越界,减少争论成本,提高效率与稳定性。
生产环境实战:从教学模型到可演进系统
1. 事件版本化与兼容性
生产里事件常 JSON 序列化入库或进 Kafka。建议从第一天就保留:
event_name、schema_version、occurred_at、trace_id(与 OpenTelemetry 对齐)。
2. 聚合设计 vs 性能
本讲 ReviewRequest 将评论存在内存列表;真实系统可能分页拉取。架构师要做的是 先正确建模边界,再为查询路径增加读模型(CQRS 思想),而不是一开始就把表结构当领域。
3. 与 LangChain 的衔接点(预告)
把 ReviewRequest.create 映射为 Tool:create_review(repository: str, change_set: str) -> ReviewRequestDTO。DTO 可以自动生成 JSON schema,作为 函数调用(tool calling) 的强约束输入输出。
类图:核心领域结构
classDiagram
class ReviewRequest {
+ReviewRequestId id
+str repository
+str change_set
+ReviewStatus status
+list~ReviewComment~ comments
+ReviewScore score
+create(repository, change_set) ReviewRequest
+start()
+add_comment(body, severity, location)
+mark_violation(location, severity, rule_code)
+complete(score)
+pull_events() list~DomainEvent~
}
class ReviewComment {
+str comment_id
+str body
+CodeLocation location
+Severity severity
}
class ReviewScore {
+float overall
+float security
+float maintainability
}
class CodeLocation {
+str path
+int start_line
+int end_line
}
class DomainEvent {
+datetime occurred_at
}
class ReviewRequestCreated
class ReviewCompleted
class ArchitectureViolationDetected
ReviewRequest "1" *-- "many" ReviewComment
ReviewRequest "1" o-- "0..1" ReviewScore
ReviewComment o-- CodeLocation
DomainEvent <|-- ReviewRequestCreated
DomainEvent <|-- ReviewCompleted
DomainEvent <|-- ArchitectureViolationDetected
ReviewCompleted --> ReviewRequestId
ReviewRequestCreated --> ReviewRequestId
ArchitectureViolationDetected --> ReviewRequestId
ArchitectureViolationDetected --> CodeLocation
深度延展:从教学模型走向可运行系统的常见分歧点
这一节讨论你把本讲代码搬进真实仓库时会遇到的“争论点”,并给出架构师常用的裁决思路。
第一,dataclass 还是 pydantic v2 模型? 在领域层,很多团队倾向纯 dataclass + 显式校验,避免把序列化与持久化注解混进领域;也有团队用 pydantic 做领域模型以统一校验与 schema 生成。CodeSentinel 后续如果希望 Tool schema 自动生成,可能会在应用层引入 DTO(pydantic)与领域对象(dataclass)的映射。关键是:不要让 Web 校验注解反向统治领域。
第二,领域事件是否要携带 trace_id? 在单进程同步总线里可有可无;一旦进入消息队列,强烈建议事件 envelope 统一携带 trace_id、correlation_id、causation_id(因果链)。这会把“可观测性”与“领域叙事”连接起来:你不仅知道发生了什么,还能串起一次审核请求的全链路。
第三,事件顺序与幂等。异步世界里,事件可能重复、乱序。消费者必须幂等:ReviewCompleted 触发报告生成时,报告服务要以 review_id 做去重键。架构师要在早期定义:哪些处理器必须严格有序,哪些可以乱序合并。把这个问题拖到上线后,会变成难以复现的幽灵 Bug。
第四,领域事件与集成事件的边界。领域事件表达业务事实;集成事件可能为了外部系统裁剪字段或合并多个领域事件。不要强行把领域事件直接当对外 webhook payload,除非你明确承诺稳定契约并接受版本治理成本。
第五,与 Clean Architecture 的合流。领域层应无框架;应用层编排;基础设施发布消息。若你把 EventBus 实现成依赖 Kafka SDK 并注入领域,就破坏了边界。正确做法是:领域只产生事件对象;应用层或基础设施层负责投递。教学代码把 EventBus 放在同一文件是为了阅读,落地时请放到合适分层。
第六,AI 工具链对齐:把领域类型映射为 JSON Schema。当你定义 Severity 枚举、CodeLocation 结构后,可以在应用层生成 schema,让工具调用更稳定。架构师要推动“工具不是随便写的脚本”,而是“对外契约”,这与传统 API 设计没有本质区别,只是调用方从人类客户端变成了模型代理。
第七,测试策略:先测不变量,再测事件列表。对每个聚合根,优先覆盖非法状态迁移(例如 complete 在 created 调用应失败),再断言 pull_events() 是否包含期望事件类型。AI 生成测试时常只测 happy path,你要在评审模板里强制要求“至少一条负例”。
案例推演:如果没有限界上下文,LLM 会把哪些词混掉?
在真实对话里,用户会说“这次报告的版本不对”。没有上下文划分时,模型可能把“报告(Report)版本”理解成“规则包(RulePack)版本”,进而错误地建议升级规则或回滚模板。限界上下文的价值是把歧义拆成 不同的类型与不同的生命周期:ReportVersion 与 RuleVersion 不应共享同一个无类型字符串字段。你在设计 API 与工具函数时,应用不同前缀与不同 schema,让模型在生成调用参数时受到约束。DDD 在这里不是学术,而是 降低误调用概率 的工程手段。
聚合根方法命名的团队规范建议
建议使用 动词短语 表达业务动作:start、complete、record_finding,避免 set_status 这种泛化 setter,因为它绕过了业务规则。AI 很擅长生成 setter,但 setter 会破坏不变量。可以在评审规范里写:领域对象禁止公开 status 的直接赋值(dataclass 要小心 frozen 与拷贝策略)。
领域事件与审计日志:不是重复,而是不同切面
审计日志偏运维合规:谁、何时、做了什么操作。领域事件偏业务叙事:系统里发生了什么有意义的事实。二者可以关联(事件附带 actor),但不应混为一个结构。CodeSentinel 未来若对接企业合规,你会感谢现在把事件类型设计得干净。
上下文映射中的“遵奉者(Conformist)”何时可接受
当外部系统极强、改造成本极高,内部模型选择服从外部模型时,会出现遵奉者关系。对 Git 平台而言很常见:你们的 ChangeSetRef 可能长期携带供应商特定字段。此时关键是把遵奉者边界放在 ACL 上,而不是让供应商 JSON 渗透到领域核心。
练习:写一页“通用语言词典 v0.1”
包含:名词定义、反例、相关事件、相关 REST 资源名。把它贴到仓库 docs/ubiquitous-language.md。这页文档会成为后续所有提示词与 OpenAPI 描述的母本。
常见误区:把“领域服务”当垃圾桶
不是所有无法归类的方法都该叫 Domain Service。若方法主要协调多个聚合或主要与外部端口交互,它更像应用服务。领域服务应承载 纯领域规则 且与特定用例无关。分不清时,先写进用例服务,等重复出现再下沉。
读模型与写模型:为模块六埋一根线
当审核记录与报告查询变得复杂,你可能需要 CQRS:写模型保持聚合一致,读模型做投影优化查询。现在不必实现,但要在事件设计时避免“根本无法投影”的字段缺失(例如缺少 review_id 外键语义)。
术语精读:DDD 核心词与 CodeSentinel 对照
限界上下文:语义一致性的边界,内部模型独立演化。通用语言:团队与代码共享的词汇表。聚合:一致性边界内的对象集群。聚合根:外部唯一入口,负责维护不变量。实体:具有标识且生命周期可追踪的对象。值对象:由属性值定义身份、通常不可变。领域事件:已发生事实的不可变记录。领域服务:不属于单一实体/值对象、但仍属领域规则的纯逻辑。仓储:聚合持久化抽象(注意与 Git 仓库语境区分)。应用服务:用例编排者。防腐层:隔离外部模型污染内部的翻译层。把每个词对应到 CodeSentinel 的具体例子,在 onboarding 时讲一遍,后续沟通成本会显著下降。
工作坊提示:事件风暴贴纸颜色与约定
常见约定:蓝色领域事件、橙色命令、黄色读模型、紫色热点、红色风险。团队选定后不要频繁更换,否则历史白板照片会失效。线上白板工具也要固定模板。
模型演进:字段新增如何不打碎旧消费者
当 ReviewCompleted 需要增加字段时,优先 加可选字段 或 新版本事件类型(ReviewCompletedV2),不要悄悄改语义。对 AI 工具链而言,schema 变化会直接影响调用成功率,版本治理同样重要。
反例:用“字符串万能字典”当领域模型
dict[str, Any] 是模型最爱的捷径,也是后期返工源泉。允许它在边界(JSON 输入)出现,但应在应用层尽快映射为强类型对象。CodeSentinel 的审核结果如果长期以字典传递,报告与规则上下文会迅速失控。
团队练习:给出一个业务句子,拆成事件链
例如“审核完成后发送通知并归档报告”。让两名工程师分别写事件列表,对齐差异。你会惊讶于同一句话隐含的不同假设——这正是通用语言要解决的问题。
与安全治理的交界:Severity 不仅是 UI 标签
BLOCKER 是否应该阻止合并?是否触发二次人工确认?这些策略属于规则与流程,不应只在界面层写死。领域里定义严重级别,应用层定义策略,基础设施层执行具体集成(调用 Git 状态 API 等)。
收束:给团队一份“DDD 最小可用包”
你不需要一次引入所有战术模式;对 CodeSentinel 而言,第一包通常包括:限界上下文划分、通用语言一页纸、聚合根与值对象、领域事件、仓储端口。战术模式(工厂、规格、领域服务等)按需引入,避免为了“像 DDD”而堆模式。
延伸:领域模型与数据库范式的张力
关系型数据库喜欢规范化,聚合喜欢事务边界;向量数据库喜欢嵌入与元数据。架构师要在建模阶段接受这种张力:领域对象表达业务真相,持久化层允许与领域不完全同形,但必须由明确 mapper 负责翻译,而不是让领域迁就表结构。
最后提醒:事件不是越多越好
事件过多会让订阅关系复杂、排障困难。每新增一个事件类型,都要回答:谁是权威生产者?谁是消费者?失败如何补偿?是否可以与现有事件合并?克制与清晰同样属于架构能力。
结语:DDD 让“业务语义”成为代码里的第一类约束
当语义被写进类型与不变量,CodeSentinel 的每一次扩展都会被迫回答:这是否仍属于同一上下文?是否破坏聚合一致性?是否需要新事件?这些问题越早被问出来,返工越少。AI 可以加速编码,但不应替你省略这些问题;相反,它更需要边界来避免幻觉扩散到核心规则里。记住:模型擅长补全,但边界负责纠错;二者组合才可靠。DDD 的价值,就是把边界写进类型与事件,让纠错发生在编译期、测试期与评审期,而不是发生在用户投诉之后。把这条原则贴在评审清单顶部。
本讲小结(思维导图)
mindmap
root((第06讲小结))
限界上下文
Review
Rule
Repository
Report
通用语言
词典一致
禁止串台
聚合根
ReviewRequest
一致性边界
值对象
CodeLocation
Severity
ReviewStatus
领域事件
Created
Completed
Violation
事件总线
进程内分发
可替换中间件
AI协同
类型即提示
Tool签名对齐
思考题
ReviewComment是否应该是独立聚合根?在什么业务条件下应该从聚合内提升为根?ArchitectureViolationDetected应该由 ReviewContext 产生,还是由 RuleContext 产生更符合语义?两种设计对协作与一致性的影响是什么?- 如果事件要跨服务传递,你会如何把
DomainEvent演进为 版本化契约 并避免“消费者解析失败”?
下一讲预告
下一讲是模块一实战收官:我们会用 事件风暴(Event Storming) 走一遍 CodeSentinel 的关键流程,把仓储协议、应用服务、事件总线与测试一次性串起来,并写下第一份 ADR:为什么选择 Clean Architecture + DDD 作为平台基线。完成后,你将拥有一张真正可执行的架构蓝图,而不仅是概念图。