06-模块一-全景认知 第06讲-DDD 领域驱动设计核心 - 限界上下文 聚合根与领域事件

4 阅读27分钟

模块一-全景认知 | 第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;有了通用语言,工具参数会变成 ReviewRequestIdChangeSetRef 这类“自带说明书的类型”。


全局视角:限界上下文与协作关系

第一张图展示 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 为根:ReviewCommentReviewScore 的生命周期依附于审核请求。外部不应直接“新增评论”而不经过根的规则校验(教学代码里用方法表达此意图)。

4. 领域事件与最终一致性

ReviewCompleted 发布后,报告上下文可以异步生成 PDF/Markdown;规则上下文可以触发额外扫描。事件不是“随便抛 dict”,而是 显式类型,便于版本演进与序列化。

5. 事件分发模式

本讲采用 进程内同步分发(简单、可测试):DomainEvent 列表挂在聚合操作上,由 EventBus 统一派发。后续模块可替换为消息队列,不改变领域核心。

6. 限界上下文划分的实操标准:不是越多越好

划分上下文时,可以用三个问题自测:第一,是否存在一套 独立演化 的模型(例如规则包版本与报告模板版本不应强行绑死);第二,是否存在不同的 一致性策略(审核流程强一致,报告生成可最终一致);第三,是否存在不同的 团队边界(即使小团队也要为未来拆分留余地)。CodeSentinel 的四个上下文在教学上清晰,但在真实公司中,你可能一开始合并为两个上下文,再随着规模拆分——这没问题,关键是 每次合并/拆分都要更新上下文映射与集成方式,否则集成就只能靠口头默契。

7. 聚合设计中的“大小”权衡:小聚合更安全

聚合越大,事务边界越大,并发冲突越多。对审核系统而言,把“评论流”“评分”“附件扫描结果”全部塞进一个超大聚合,可能导致高频写入互相锁死。更稳妥的路线通常是:以 ReviewRequest 为核心聚合,其他数据若生命周期独立且一致性要求弱,考虑提升为独立聚合并通过事件协同。教学模型为了可读性会放在同一聚合内,但你在架构评审时要问:这是否会在高并发 PR 审核场景成为瓶颈

8. 值对象与实体的选择:不要为“没有标识”硬造标识

CodeLocation 适合值对象:它由字段组合定义身份,不追求长期追踪。ReviewComment 在教学里给了 comment_id,更像实体行为;若评论完全依附于审核且不需要跨聚合引用,也可以让评论成为聚合内实体但不对外暴露全局 ID。DDD 没有唯一标准答案,但必须 自洽:对外 API 暴露的标识体系要与模型一致,否则前端、Webhook、审计系统会对不上。

9. 领域事件的命名:用过去时表达事实

ReviewCompletedCompleteReview 更适合事件名,因为事件是“已发生”。命令层可以用 CompleteReview,事件层用过去时,能在日志与消息队列里显著降低误读。对 AI 生成代码而言,命名规范是廉价但高杠杆的约束:让模型按规范生成,比事后纠正成本低一个数量级。

10. 防腐层(ACL)在什么时候值得引入

当外部系统(例如 Git 平台的 PR 模型)与内部领域模型差异很大时,直接在用例里拼装字典会把外部概念泄漏进来。ACL 的目标是把外部模型翻译为内部模型,让领域保持干净。CodeSentinel 后续在对接不同 Git 供应商时,ACL 往往会出现在 RepositoryContextReviewContext 的边界上。你现在不需要实现它,但要在脑子里预留位置:集成复杂度会推动 ACL 出现,而不是“为了模式而模式”。

11. 战略设计与战术设计:本讲聚焦战术,但不要丢失战略

DDD 常分战略(上下文划分、协作关系)与战术(聚合、实体、值对象、领域服务)。CodeSentinel 的战略结论来自业务拆分与风险拆分:审核、规则、仓库、报告必须能独立演化。战术设计把战略落地为可执行代码。若你只学战术不学战略,最容易出现“模型很漂亮但上下文串台”;若只画战略不写战术,则会出现“PPT 正确、代码全错”。本讲代码属于战术样板,请始终把它挂回前面的上下文图理解。

12. 领域模型的可测试性:不变量必须可触发

每个 __post_init__ 或聚合方法里的校验,都应有对应测试用例触发失败路径。否则不变量只是“写在纸上的规则”。AI 生成领域代码时常常漏掉负例测试,架构师要在 PR 模板里强制要求:每个新不变量至少一条失败测试。这会把 DDD 从“文档活动”变成“工程活动”。

13. 与持久化映射的边界:领域对象不要为表结构妥协

你会遇到“数据库里评论是另一张表”的情况,领域仍可以保持聚合内列表;映射层负责拆行与组装。不要在领域对象里加入 db_row_id 这类纯粹持久化字段,除非你明确这是技术主键且不会污染业务决策。若分不清楚,先坚持领域纯净,再在基础设施写 mapper。


代码实战:完整领域模型(dataclasses + 校验 + 事件)

下列代码为 教学用紧凑实现:放在一个模块里便于阅读;真实项目应拆到 domain/review/ 等子包,并补充更严格的 ID 生成与持久化映射。

代码走读:逐段理解“聚合根如何守住边界”

导入与基础类型Enum 用于 ReviewStatusSeverity,让非法枚举值在构造期失败;这比在业务里到处 if x not in (...) 更干净。uuid4 生成标识符是教学默认,生产可换 ULID/UUID7 以满足排序与索引友好。utcnow 单独函数是为了可测试:未来你可以在测试里 monkeypatch 时间,而不改领域代码。

值对象 CodeLocationfrozen=True 强调不可变;__post_init__ 把坐标系规则写死(行号从 1 开始、区间合法)。这对应通用语言里“代码位置”的定义:不是任意三元组字符串,而是受约束结构。对 CodeSentinel,后续很多规则违例都会携带 location,值对象统一能减少重复校验。

实体 ReviewCommentReviewScore:评论允许 location 为空,表示整文件级别意见;评分三分量约束在 0..100,体现“评分卡”业务。若你未来要支持不同评分维度,优先扩展值对象字段并同步更新通用语言,而不是偷偷加 dict

事件类型层次DomainEvent 基类携带 occurred_at,保证每条事件都有时间线。具体事件只增加必要字段,避免“万能 payload”。ArchitectureViolationDetected 同时携带 rule_codelocation,为报告上下文提供可聚合维度。事件字段越清晰,后续做数据仓库与指标越省事。

聚合根 ReviewRequest:对外方法表达业务动作:create 工厂、startadd_commentmark_violationcomplete。内部 _events 列表与 pull_events 是典型模式:避免事件重复发布。注意 mark_violationadd_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 作为异常完成态(后续讲次会用到)。这意味着你的通用语言里必须定义:什么算“完成”,什么算“失败”,失败是否可重试。不要把状态机隐藏在 boolstr 里;状态机是审核域的核心结构之一,值得显式建模与测试。AI 生成代码时常常用 status: str 凑合,你要把它打回去改成枚举或受控集合。

继续走读(评论 vs 违例:为什么要两条路径)add_comment 产生 ReviewComment 实体,适合展示给开发者阅读;mark_violation 记录 ArchitectureViolationDetected 事件,更适合进入治理看板与趋势分析。两者在真实系统可能合并,也可能拆分;关键是团队在词典里解释清楚“评论是否可能升级为违例”“违例是否必须附带修复建议”。没有词典,模型会把它们混写成同一种结构。

继续走读(评分作为值对象的边界)ReviewScore 放在 complete 时一次性写入,避免运行中半成品的分数被外部读取。若你需要“运行中实时评分”,要么引入新的显式状态,要么拆分为独立聚合,由事件同步。不要一边 running 一边允许分数突变却不记录事件,否则审计无法解释“分数为何变化”。

继续走读(事件总线的责任边界)EventBus.publish 不应捕获所有异常并吞掉;那会让处理器失败静默。生产更常见的是:记录失败、重试、死信队列。教学代码用 print 仅示意。你要记住:订阅者失败是否应回滚聚合事务 是架构决策,不是实现细节。很多时候领域事务已提交,事件处理失败要走补偿。

继续走读(与 LangChain 工具的参数映射):把 ReviewRequestIdChangeSetRefSeverity 映射为工具参数类型时,优先使用 JSON Schema 能表达的枚举与结构,避免把 CodeLocation 拆成四个松散参数导致模型漏传。工具描述里引用通用语言词汇(中文或英文统一一种),减少“同名异义”。

继续走读(性能与聚合加载):当评论数量极大时,一次性加载聚合可能过重。此时常见方案是:聚合根保存计数与摘要,评论详情走读模型分页查询;或把评论拆为独立聚合,通过 review_id 关联。无论哪种,都属于战略性调整,应通过 ADR 记录,而不是悄悄改代码。

继续走读(安全与隐私):评论文本可能包含代码片段,代码片段可能包含密钥泄露风险。领域层可以定义“提交前必须脱敏”的规则,但具体脱敏实现可放在基础设施(检测密钥模式)。不要把正则堆进实体方法里导致难以测试;用端口 SecretRedactor 更清晰。

继续走读(国际化):若 CodeSentinel 面向多语言团队,通用语言可以双语,但事件与内部模型字段建议固定一种“canonical language”,UI 翻译放在表现层。不要把多语言字符串混进领域事件 payload,否则报表聚合会痛苦。

继续走读(如何从本模块迁移到仓储):下一步你会把 ReviewRequest 持久化。记住:仓储接口应以聚合根为粒度 save/load,不要暴露“更新某条评论”这种穿透聚合的方法,除非你已经明确放弃该一致性边界。

与四个上下文的落位建议

  • ReviewRequest* 属于 ReviewContext 的核心聚合。
  • ArchitectureViolationDetected 是跨上下文信号:规则上下文可消费它来更新统计;报告上下文可聚合展示。
  • CodeLocationSeverity值对象:尽量在多个上下文复用同一实现,或通过 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_nameschema_versionoccurred_attrace_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_idcorrelation_idcausation_id(因果链)。这会把“可观测性”与“领域叙事”连接起来:你不仅知道发生了什么,还能串起一次审核请求的全链路。

第三,事件顺序与幂等。异步世界里,事件可能重复、乱序。消费者必须幂等:ReviewCompleted 触发报告生成时,报告服务要以 review_id 做去重键。架构师要在早期定义:哪些处理器必须严格有序,哪些可以乱序合并。把这个问题拖到上线后,会变成难以复现的幽灵 Bug。

第四,领域事件与集成事件的边界。领域事件表达业务事实;集成事件可能为了外部系统裁剪字段或合并多个领域事件。不要强行把领域事件直接当对外 webhook payload,除非你明确承诺稳定契约并接受版本治理成本。

第五,与 Clean Architecture 的合流。领域层应无框架;应用层编排;基础设施发布消息。若你把 EventBus 实现成依赖 Kafka SDK 并注入领域,就破坏了边界。正确做法是:领域只产生事件对象;应用层或基础设施层负责投递。教学代码把 EventBus 放在同一文件是为了阅读,落地时请放到合适分层。

第六,AI 工具链对齐:把领域类型映射为 JSON Schema。当你定义 Severity 枚举、CodeLocation 结构后,可以在应用层生成 schema,让工具调用更稳定。架构师要推动“工具不是随便写的脚本”,而是“对外契约”,这与传统 API 设计没有本质区别,只是调用方从人类客户端变成了模型代理。

第七,测试策略:先测不变量,再测事件列表。对每个聚合根,优先覆盖非法状态迁移(例如 completecreated 调用应失败),再断言 pull_events() 是否包含期望事件类型。AI 生成测试时常只测 happy path,你要在评审模板里强制要求“至少一条负例”。

案例推演:如果没有限界上下文,LLM 会把哪些词混掉?

在真实对话里,用户会说“这次报告的版本不对”。没有上下文划分时,模型可能把“报告(Report)版本”理解成“规则包(RulePack)版本”,进而错误地建议升级规则或回滚模板。限界上下文的价值是把歧义拆成 不同的类型与不同的生命周期ReportVersionRuleVersion 不应共享同一个无类型字符串字段。你在设计 API 与工具函数时,应用不同前缀与不同 schema,让模型在生成调用参数时受到约束。DDD 在这里不是学术,而是 降低误调用概率 的工程手段。

聚合根方法命名的团队规范建议

建议使用 动词短语 表达业务动作:startcompleterecord_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签名对齐

思考题

  1. ReviewComment 是否应该是独立聚合根?在什么业务条件下应该从聚合内提升为根?
  2. ArchitectureViolationDetected 应该由 ReviewContext 产生,还是由 RuleContext 产生更符合语义?两种设计对协作与一致性的影响是什么?
  3. 如果事件要跨服务传递,你会如何把 DomainEvent 演进为 版本化契约 并避免“消费者解析失败”?

下一讲预告

下一讲是模块一实战收官:我们会用 事件风暴(Event Storming) 走一遍 CodeSentinel 的关键流程,把仓储协议、应用服务、事件总线与测试一次性串起来,并写下第一份 ADR:为什么选择 Clean Architecture + DDD 作为平台基线。完成后,你将拥有一张真正可执行的架构蓝图,而不仅是概念图。