07-模块一-全景认知 第07讲-模块实战 - 用 DDD 建模 CodeSentinel 的领域模型与架构蓝图

0 阅读22分钟

模块一-全景认知 | 第07讲:模块实战 - 用 DDD 建模 CodeSentinel 的领域模型与架构蓝图

开场:把“讨论出来的架构”变成“能跑起来的架构”

模块一的前几讲分别补齐了工程骨架、Clean Architecture 依赖规则、DDD 核心概念。到本讲,我们要做一次 集成式落地:用 事件风暴(Event Storming) 把 CodeSentinel 的主线流程从白板语言翻译成 领域模型 + 用例服务 + 端口 + 事件总线,并写出第一份 架构决策记录(ADR),回答一个会被反复问到的问题:为什么 CodeSentinel 选择 Clean Architecture + DDD?

这份实战刻意模拟真实团队的“第一次收敛”:领域不会一次完美,但 边界与契约 必须先立住。你会看到:

  • 领域对象带 校验领域事件
  • 应用服务 只依赖端口(Protocol),不碰具体存储;
  • EventBus 派发领域事件,为报告与规则联动留钩子;
  • pytest 用例证明:不启动数据库 也能验证核心业务规则。

完成本讲后,模块一的交付物不再只是“懂概念”,而是 可测试、可演进、可对接 LangChain 工具链 的底座。下一模块我们将把 LLM 调用严格放进基础设施端口,让智能成为可替换组件。

把开场写得更“像一次真实迭代复盘”,是为了让你记住:模块实战不是抄代码,而是 把决策落到可验证的工件。事件风暴的输出应当能映射到:哪些聚合状态合法哪些事件必须出现哪些失败需要被业务显式建模(例如本讲的 ReviewFailed)。当你后面让 AI 帮你生成更多用例时,你可以把这些工件当作提示词的硬约束:模型可以生成实现细节,但不应随意发明新的状态迁移,除非你们同步更新通用语言与 ADR。


全局视角:事件风暴切片(Happy Path + 异常分支)

事件风暴强调 从业务流程倒推领域事件。下图给出 CodeSentinel 在“提交一次 PR 审核”场景下的教学版事件链(橙色为命令,蓝色为领域事件)。

flowchart TB
  C1["命令:CreateReview"] --> E1["ReviewRequestCreated"]
  C2["命令:StartReview"] --> E2["ReviewStarted"]
  C3["命令:AddFinding"] --> E3["FindingRecorded"]
  C4["命令:CompleteReview"] --> E4["ReviewCompleted"]

  E1 --> C2
  E2 --> C3
  E3 --> C4

  E2 -.-> X1["可选:ArchitectureViolationDetected"]
  C4 -.-> X2["失败:ReviewFailed"]

第二张图是 上下文映射(Context Map) 的实战版:标出集成方式(同步调用 / 事件 / ACL)。

flowchart LR
  subgraph Review["ReviewContext"]
    RR["ReviewRequest AR"]
  end

  subgraph Rule["RuleContext"]
    RP["RulePack"]
    RV["RuleEvaluatorPort"]
  end

  subgraph Repo["RepositoryContext"]
    RC["RepositoryGateway"]
  end

  subgraph Report["ReportContext"]
    RG["ReportBuilder"]
  end

  Review -->|同步:拉取变更| Repo
  Review -->|同步:评估规则| Rule
  Review -->|事件:ReviewCompleted| Report
  Rule -.->|事件:Violation| Report

核心原理:事件风暴如何落到分层

1. 命令 vs 领域事件

  • 命令(Command) 表达意图,可能失败(权限不足、状态不合法)。
  • 领域事件(Domain Event) 表达“已经发生的事实”,通常不可变。

在应用服务里,我们对外暴露 用例方法(例如 create_review),对内驱动聚合根状态迁移,并通过 pull_events() 收集事实。

2. 仓储协议属于内层契约

ReviewRepository 定义 save/get,由内存实现或 ORM 实现。应用服务依赖协议,测试替换为内存仓储即可 快速反馈

3. 规则评估是端口,不是 SDK

RuleEvaluatorPort 代表“我们能问规则引擎一个问题”。未来实现可以是:

  • 纯静态规则(AST 分析);
  • Semgrep/自定义 linter;
  • LLM+约束解码(风险更高,需要治理)。

无论哪种实现,都应放在 基础设施层,并通过端口注入。

4. ADR 是什么?为什么要现在写?

ADR(Architecture Decision Record)记录 关键决策、背景、后果。它不是为了“写文档而写文档”,而是为了防止六个月后团队问:“谁当初决定这么分层?”却无法追溯。

本讲 ADR 标题:ADR-0001:采用 Clean Architecture + DDD 作为 CodeSentinel 基线

5. 应用服务的职责边界:编排,不等于“写业务的一切”

应用服务负责 用例级别的编排:调用端口、驱动聚合根、在合适的点发布事件、处理跨聚合一致性策略(例如先落库再发事件,或先发事件再补偿,取决于你们的事务模型)。但它不应变成“上帝类”,把所有算法细节都塞进去。规则评估的细节属于规则上下文;变更集存在性属于仓库上下文。应用服务更像指挥,而不是乐器演奏者。AI 生成代码时最常把“指挥”写成“万能脚本”,评审时要主动拆分。

6. 事务边界:本讲用内存仓储,但你要提前想数据库

内存实现里 save 往往是引用赋值;数据库实现里 save 可能意味着 flush/commit。应用服务层要定义:一个用例对应一个工作单元(Unit of Work) 的粒度。未来你会遇到“审核写入成功但事件投递失败”的经典难题,那时需要 outbox 模式或事务消息。现在不实现没关系,但架构师要在团队里先播下种子,否则上线后只能临时打补丁。

7. 失败建模:ReviewFailed 是业务结果还是异常?

本讲选择把“变更集不存在”建模为聚合失败状态并记录 ReviewFailed 事件,而不是抛异常到路由层。这样报告上下文可以把失败当作一种可展示结果(例如“审核无法启动:变更不可达”)。是否抛异常取决于你是否认为这是 不可恢复的编程错误 还是 可预期的业务分支。对平台集成类系统,后者更常见。

8. 事件风暴工作坊的组织要点(线上/线下通用)

事件风暴最怕变成架构师独白。建议流程是:先贴命令与事件(橙色/蓝色便利贴),再补读模型与策略,最后收敛聚合与上下文。时间盒控制在 90~120 分钟内,超出就记录“未决问题清单”。CodeSentinel 这种平台型系统,第一次工作坊不必覆盖所有功能,只要覆盖 主链路 + 最高风险集成点(Git、规则、LLM、报告)。

9. 如何把事件风暴映射到测试用例

每一个“必须发生的事件”都应对应一条测试断言或 spy;每一个“禁止的状态迁移”都应有负例测试。这样事件风暴不会停留在墙上,而会变成 活的规格。AI 辅助生成测试时,把事件列表贴进提示词,通常比只贴接口定义更有效。

10. 模块一收官标准:你可以向团队演示什么?

建议准备 3 分钟演示:创建审核、启动审核(规则评估返回 finding、产生违例事件)、完成审核;同时展示 pytest 在几秒内跑完。演示的目标是证明:我们不是讨论架构,而是架构已经能跑

11. 用例服务与领域协作的“最小权限原则”

应用服务只应调用聚合根公开方法完成状态迁移,不应直接修改聚合内部列表或绕过校验。否则你会在六个月后发现:某些修复补丁为了省事直接 review.findings.append(...),不变量彻底失效。CodeSentinel 的审核域对一致性敏感,最小权限原则要从第一天写成评审习惯。

12. 端口命名:用“领域语言”而不是“技术语言”

RepositoryGateway 表达“我们向仓库上下文询问事实”,比 GitClientPort 更稳定;后者会把供应商名字写进核心语义。技术实现可以变,但端口名应尽量反映业务能力,减少替换供应商时的语义地震。

13. 失败、异常与事件的三位一体

本讲同时演示:KeyError(找不到审核)、ValueError(领域不变量)、ReviewFailed(业务失败事件)。上线前要统一策略:哪些映射为 HTTP 4xx,哪些映射为 5xx,哪些进入告警。不要把三者混成一团,否则监控与用户体验都会变差。

14. 事件风暴产出物如何进仓库

建议最小集合:docs/event-storming/module1-board.png(或 Mermaid 源文件)、docs/context-map.mddocs/ubiquitous-language.md。没有产出物,工作坊等于没开;产出物越轻越好,但必须可检索。

15. 本讲与第04讲骨架的合并策略

优先把单文件拆进 src/codesentinel,保持 import 一致;其次再补 FastAPI 路由。不要反过来先写路由再补领域,否则很容易又把业务写回表现层。

16. 用一页纸记录“未决问题”比当场争论更重要

事件风暴结束时常会留下未决问题:例如报告是否需要法定保留期、规则包是否允许热更新、LLM 输出是否可作为法律依据。把它们记在 docs/open-questions.md 并标注负责人与截止日期,避免口头结论在两周后变形。未决问题不是失败,而是专业过程的正常产物;真正失败的是没有记录与跟进。

17. 把“演示数据”与“生产数据”从命名上分开

教学仓库常用 org/apppr-7 这类占位符;进入联调后要换成明确的 fixture 命名空间,避免误连真实仓库。可以在文档里规定:示例数据必须带 demo- 前缀或使用专用测试组织。


代码实战:领域模型 + 端口 + 应用服务 + 事件总线 + 测试

如何运行(重要):将下方“完整可运行代码”保存为仓库根目录的 codesentinel_module1_capstone.py;将 pytest 代码保存为 tests/test_module1_capstone.py。若你暂时未使用 src 包布局,可在项目根执行 uv run pytest -q(pytest 会从项目根收集测试)。若导入失败,请在 Windows PowerShell 临时设置 $env:PYTHONPATH=".",或在 Linux/macOS 执行 export PYTHONPATH=.,确保根目录可作为模块搜索路径。

更推荐的做法是:把本讲代码 拆分 进第04讲建立的 src/codesentinel/ 包结构,并把测试里的 import 改为 from codesentinel...——这与生产代码组织一致。此处单文件是为降低复制成本。

在真实仓库中请按包拆分:domain/application/ports/(或 application/ports.py)、tests/

代码走读:从领域对象到 ReviewAppService 的装配链

总览:这段 capstone 把“聚合 + 事件 + 端口 + 用例 + 假实现 + 测试”压进一个文件,是为了让你对照运行;拆包时按文件名落位即可。阅读顺序建议:先事件与枚举,再值对象与聚合根,再端口,再 ReviewAppService,最后测试。

ChangeSetRef 的价值:把 change_set 从裸 str 提升为值对象,能在入口处统一校验,并为未来扩展 pr_number/sha 留位置。AI 生成集成代码时最常直接用字符串,你会在日志里看到大量无法关联的标识;值对象是低成本治理。

ReviewFindingFindingRecordedrecord_finding 同时更新聚合内列表与事件流,保证“事实可回放”。注意这与 ArchitectureViolationDetected 并存:教学上我们让两者都出现,真实系统要由规则引擎决定事件类型,不要重复记录同一事实。

ReviewAppService.create_review:创建聚合后立即 savepublish(pull_events()), pattern 是“持久化与事件外送同事务边界要后续用 outbox 强化”。现在先理解:事件必须在一致性策略允许时发布,不要 half-save。

start_review 的分支:变更集不存在时走 fail 分支并发布事件;存在时 start,再 evaluate 规则,再 mark_architecture_violation(教学固定)。真实实现里,第三步应来自规则端口返回的结构化结果,而不是写死。

FakeRepoGatewayFakeRules:它们是测试替身,也是文档:告诉你端口方法长什么样、返回什么粒度。新人读测试往往比读接口文件更快理解系统。

把走读记住后,再进入完整代码阅读会快很多。

完整可运行代码

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(frozen=True)
class ChangeSetRef:
    """仓库上下文的值对象:在真实系统可扩展为 commit/sha/pr_number。"""

    ref: str

    def __post_init__(self) -> None:
        if not self.ref.strip():
            raise ValueError("change_set 不能为空")


@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: ChangeSetRef


@dataclass(frozen=True)
class ReviewStarted(DomainEvent):
    review_id: ReviewRequestId


@dataclass(frozen=True)
class FindingRecorded(DomainEvent):
    review_id: ReviewRequestId
    location: CodeLocation
    severity: Severity
    message: str


@dataclass(frozen=True)
class ArchitectureViolationDetected(DomainEvent):
    review_id: ReviewRequestId
    location: CodeLocation
    severity: Severity
    rule_code: str


@dataclass(frozen=True)
class ReviewCompleted(DomainEvent):
    review_id: ReviewRequestId
    findings: int


@dataclass(frozen=True)
class ReviewFailed(DomainEvent):
    review_id: ReviewRequestId
    reason: str


@dataclass
class ReviewFinding:
    finding_id: str
    location: CodeLocation
    severity: Severity
    message: str

    def __post_init__(self) -> None:
        if not self.message.strip():
            raise ValueError("finding message 不能为空")


@dataclass
class ReviewRequest:
    id: ReviewRequestId
    repository: str
    change_set: ChangeSetRef
    status: ReviewStatus
    findings: list[ReviewFinding] = field(default_factory=list)
    _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 不能为空")
        rid = ReviewRequestId.new()
        cs = ChangeSetRef(ref=change_set)
        rr = ReviewRequest(
            id=rid,
            repository=repository.strip(),
            change_set=cs,
            status=ReviewStatus.CREATED,
        )
        rr._record(ReviewRequestCreated(review_id=rid, repository=rr.repository, change_set=cs))
        return rr

    def start(self) -> None:
        if self.status != ReviewStatus.CREATED:
            raise ValueError("只有 created 才能 start")
        self.status = ReviewStatus.RUNNING
        self._record(ReviewStarted(review_id=self.id))

    def record_finding(self, location: CodeLocation, severity: Severity, message: str) -> ReviewFinding:
        if self.status != ReviewStatus.RUNNING:
            raise ValueError("运行中才能记录 finding")
        f = ReviewFinding(finding_id=str(uuid4()), location=location, severity=severity, message=message.strip())
        self.findings.append(f)
        self._record(
            FindingRecorded(
                review_id=self.id,
                location=location,
                severity=severity,
                message=f.message,
            )
        )
        return f

    def mark_architecture_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) -> None:
        if self.status != ReviewStatus.RUNNING:
            raise ValueError("只有 running 才能完成")
        self.status = ReviewStatus.COMPLETED
        self._record(ReviewCompleted(review_id=self.id, findings=len(self.findings)))

    def fail(self, reason: str) -> None:
        if not reason.strip():
            raise ValueError("reason 不能为空")
        self.status = ReviewStatus.FAILED
        self._record(ReviewFailed(review_id=self.id, reason=reason.strip()))

    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 ReviewRepository(Protocol):
    def save(self, review: ReviewRequest) -> None: ...

    def get(self, review_id: ReviewRequestId) -> ReviewRequest | None: ...


@runtime_checkable
class RepositoryGateway(Protocol):
    """仓库上下文端口:拉取变更元数据(教学版返回 bool)。"""

    def change_set_exists(self, repository: str, change_set: ChangeSetRef) -> bool: ...


@runtime_checkable
class RuleEvaluatorPort(Protocol):
    """规则上下文端口:返回需要记录的 findings(教学版)。"""

    def evaluate(self, repository: str, change_set: ChangeSetRef) -> list[tuple[CodeLocation, Severity, str]]: ...


class InMemoryReviewRepository:
    def __init__(self) -> None:
        self._data: dict[str, ReviewRequest] = {}

    def save(self, review: ReviewRequest) -> None:
        self._data[review.id.value] = review

    def get(self, review_id: ReviewRequestId) -> ReviewRequest | None:
        return self._data.get(review_id.value)


@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)


@dataclass
class ReviewAppService:
    reviews: ReviewRepository
    repos: RepositoryGateway
    rules: RuleEvaluatorPort
    bus: EventBus

    def create_review(self, repository: str, change_set: str) -> ReviewRequest:
        rr = ReviewRequest.create(repository=repository, change_set=change_set)
        self.reviews.save(rr)
        self.bus.publish(rr.pull_events())
        return rr

    def start_review(self, review_id: ReviewRequestId) -> ReviewRequest:
        rr = self._must_get(review_id)
        cs = rr.change_set
        if not self.repos.change_set_exists(rr.repository, cs):
            rr.fail("变更集不存在或不可访问")
            self.reviews.save(rr)
            self.bus.publish(rr.pull_events())
            return rr

        rr.start()
        self.reviews.save(rr)
        self.bus.publish(rr.pull_events())

        for loc, sev, msg in self.rules.evaluate(rr.repository, cs):
            rr.record_finding(loc, sev, msg)

        # 教学示例:固定触发一次架构违例事件(真实系统由规则引擎决定)
        rr.mark_architecture_violation(CodeLocation("src/presentation/routes.py", 1, 40), Severity.WARN, "ACL-001")

        self.reviews.save(rr)
        self.bus.publish(rr.pull_events())
        return rr

    def complete_review(self, review_id: ReviewRequestId) -> ReviewRequest:
        rr = self._must_get(review_id)
        rr.complete()
        self.reviews.save(rr)
        self.bus.publish(rr.pull_events())
        return rr

    def _must_get(self, review_id: ReviewRequestId) -> ReviewRequest:
        rr = self.reviews.get(review_id)
        if rr is None:
            raise KeyError("review 不存在")
        return rr

pytest 验证(与教学文件同目录运行)

import pytest

from codesentinel_module1_capstone import (
    ChangeSetRef,
    CodeLocation,
    EventBus,
    InMemoryReviewRepository,
    ReviewAppService,
    ReviewCompleted,
    ReviewRequestCreated,
    ReviewRequestId,
    ReviewStarted,
    Severity,
)


class FakeRepoGateway:
    def __init__(self, ok: bool = True) -> None:
        self.ok = ok

    def change_set_exists(self, repository: str, change_set: ChangeSetRef) -> bool:
        return self.ok


class FakeRules:
    def evaluate(self, repository: str, change_set: ChangeSetRef):
        return [
            (CodeLocation("src/a.py", 10, 12), Severity.INFO, "命名可读性良好"),
        ]


def test_happy_path_events():
    bus = EventBus()
    log: list[str] = []

    bus.subscribe(ReviewRequestCreated, lambda e: log.append("created"))
    bus.subscribe(ReviewStarted, lambda e: log.append("started"))
    bus.subscribe(ReviewCompleted, lambda e: log.append("completed"))

    svc = ReviewAppService(
        reviews=InMemoryReviewRepository(),
        repos=FakeRepoGateway(ok=True),
        rules=FakeRules(),
        bus=bus,
    )

    rr = svc.create_review("org/app", "pr-7")
    assert "created" in log

    rid = ReviewRequestId(rr.id.value)
    svc.start_review(rid)
    assert "started" in log

    svc.complete_review(rid)
    assert "completed" in log


def test_change_set_missing_fails():
    bus = EventBus()
    svc = ReviewAppService(
        reviews=InMemoryReviewRepository(),
        repos=FakeRepoGateway(ok=False),
        rules=FakeRules(),
        bus=bus,
    )
    rr = svc.create_review("org/app", "pr-missing")
    rid = ReviewRequestId(rr.id.value)
    out = svc.start_review(rid)
    assert out.status.value == "failed"

继续走读(测试在验证什么)test_happy_path_eventslog 列表订阅事件类型,证明关键业务里程碑确实以事件形式外显;这比只断言最终状态更接近事件风暴精神。test_change_set_missing_fails 验证失败分支不会抛未处理异常,而是把聚合推入 failed 并可被上层映射为业务结果。你可以扩展第三条测试:规则评估抛出异常时,用例应捕获并 fail 或转换为领域错误——这属于韧性设计,将在后续模块完善。

继续走读(导入路径与工程化):单文件模块名 codesentinel_module1_capstone 是教学妥协;合并进 src/codesentinel 后,应变为 codesentinel.domain... 等路径,并在 pyproject.toml 保持包可见。不要在生产代码保留 module1_capstone 这种临时名超过一个迭代。

继续走读(与 ADR 的对应关系):代码层面的端口与事件,是 ADR-0001 的“证据”。评审时如果代码违反 ADR,要么改代码,要么更新 ADR;不允许默默偏离。

运行:

uv run pytest -q

若你使用单文件落地方式但仍遇到导入问题,也可以临时执行:

# Windows PowerShell
$env:PYTHONPATH="."
uv run pytest -q tests/test_module1_capstone.py

模块结束后的推荐目录结构(对接第04讲骨架)

src/codesentinel/
├── domain/
│   ├── review/
│   │   ├── entities.py
│   │   ├── events.py
│   │   └── value_objects.py
│   └── shared/
├── application/
│   ├── ports/
│   │   ├── repositories.py
│   │   ├── repository_gateway.py
│   │   └── rule_evaluator.py
│   ├── services/
│   │   └── review_app_service.py
│   └── bus.py
├── infrastructure/
│   ├── persistence/
│   └── rules/
└── presentation/
    └── routes_review.py

目录结构走读:为什么这样拆文件就能接住后续模块

domain/review 把实体、事件、值对象分开,是为了降低合并冲突频率:不同同学改事件与改实体不会总撞车。application/ports 集中放协议,评审时一眼扫到系统对外部世界的要求。application/services 放用例编排,避免与路由文件互相 import 形成环。infrastructure/persistenceinfrastructure/rules 分隔“数据面”和“规则执行面”,让替换 LLM 实现时不触碰数据库映射。presentation/routes_review.py 只暴露 HTTP,不把规则细节泄漏给前端。bus.py 单独存在,是因为事件分发迟早变复杂:中间件、异步、指标都要挂在这里,而不是散落在 main.py

当你把单文件 capstone 映射到该结构时,建议顺序是:先搬领域与事件(保证无框架 import),再搬端口与应用服务,再写基础设施假实现与真实现,最后接路由。任何一步反过来,都会提高“领域被污染”的概率。这个顺序也是给 AI 下任务时的最佳切片:每次 PR 只做一层迁移,CI 仍然绿。

与测试目录的协同tests/unit/domaintests/unit/applicationtests/integration 的分法建议在第二次迭代引入;第一次只要保证 pytest 能快速跑通关键用例即可。不要过早把目录复杂化,但要提前想好入口,否则测试结构会在第三周崩坏。

与配置的关系RuleEvaluatorPort 的具体实现可能需要读取规则包路径或远程地址,这些属于 settings,由组装点注入实现类,而不是在端口定义里写死常量。这样 staging 与 prod 才能差异配置而不改代码。

与可观测性的关系:目录拆清后,日志也应分层:presentation 记录请求标识,application 记录用例步骤与业务决策,infrastructure 记录外部调用耗时与错误码。不要把所有日志塞进路由,否则你无法按层聚合指标。

与权限模型的关系:后续你会在 presentation 加鉴权,在 application 校验租户与资源归属。记得:权限是横切关注点,但决策应在用例层显式出现,而不是藏在 ORM 或数据库视图里,否则审计说不清。

与数据迁移的关系:一旦 ReviewRequest 落库,就会遇到 Alembic 迁移。请把迁移脚本视为“基础设施变更”,在 PR 中单独审查;不要与领域规则改动混在一个巨大 diff 里,降低可读性。

与多版本 API 的关系:当 routes_review.py 需要 v2 时,可以新增 routes_review_v2.py 或在 router 层做版本前缀,但领域模型应尽量统一,差异集中在 DTO 与兼容层,避免两个版本的领域逻辑漂移。

与性能测试的关系:当 start_review 连接真实 Git 与真实规则引擎后,要在集成环境压测“冷启动审核”的耗时,并把瓶颈定位到具体端口实现(网络、解析、索引),而不是笼统怪“服务慢”。目录清晰时,性能优化更容易局部化。

与发布节奏的关系:建议领域与应用变更与基础设施变更分轨发布:先发布兼容端口与双实现,再切换流量;不要一次 PR 同时改模型、改表结构、改规则引擎版本。CodeSentinel 这种系统,发布节奏比单次功能炫技更重要。

与文档生成的关系ports 目录很适合自动生成接口目录页(哪怕先用脚本扫描 Protocol),让新同学一小时理解系统边界。文档自动化是架构治理的放大器。把自动化纳入 CI,而不是手工维护,才能避免文档与代码再次分叉。

与代码审查模板的关系:建议 PR 描述固定包含:是否触碰上下文边界、是否新增事件、是否需要迁移、是否需要更新 ADR。模板越轻越好,但字段不能缺,否则审查会退化为风格争论。


生产环境实战:ADR-0001 正文(示例)

ADR-0001:采用 Clean Architecture + DDD 作为 CodeSentinel 基线

  • 状态:Accepted
  • 日期:2026-03-22
  • 决策者:架构组(示例)
背景

CodeSentinel 需要集成多种外部系统(Git 托管、静态规则、LLM、向量检索、报表导出)。团队预期高频迭代,且会大量使用 AI 辅助编码。若缺乏清晰边界,概率组件容易污染核心业务规则,导致测试困难、回滚困难、审计困难。

决策
  1. 采用 Clean Architecture 约束源码依赖方向,LLM/向量库/HTTP SDK 仅出现在基础设施层并通过端口注入。
  2. 采用 DDD 统一语义:划分 Review/Rule/Repository/Report 等限界上下文,以聚合根维护一致性,以领域事件表达事实。
  3. 应用服务编排用例,领域层保持框架无关。
后果
  • 优点:可测试性提升;替换供应商成本降低;AI 生成代码更容易被规则检测“越层”。
  • 缺点:初期样板代码更多;需要配套 import 检查与评审文化。
  • 缓解:提供模板与示例用例;CI 引入依赖方向检查;在 ADR 中持续记录边界调整。
度量与复盘(建议三个月后回顾)
  • 指标:P0 缺陷中“分层越界/语义串台”占比是否下降;新功能从需求到可测用例的平均时间是否缩短;替换某个外部供应商(例如模型网关)所需人天是否可控。
  • 复盘问题:我们是否把领域事件真正用于可观测性?还是只停留在 print?Outbox 是否已纳入路线图?
ADR 模板要素(后续 ADR 可复用)
  • 标题与编号ADR-XXXX:动词短语
  • 状态:Proposed / Accepted / Deprecated / Superseded
  • 上下文(Context):触发决策的技术与业务背景
  • 决策(Decision):我们选择了什么
  • 后果(Consequences):收益、代价、风险、缓解
  • 替代方案(Alternatives):我们明确放弃了什么,以及为什么

事件风暴全景图(教学版)

flowchart TB
  subgraph Orange["热点域事件"]
    E1["ReviewRequestCreated"]
    E2["ReviewStarted"]
    E3["FindingRecorded"]
    E4["ArchitectureViolationDetected"]
    E5["ReviewCompleted"]
  end

  subgraph Policies["策略与规则"]
    P1["变更集必须存在"]
    P2["只有 running 才能记录 finding"]
    P3["完成后不可再改 findings"]
  end

  E1 --> P1
  E2 --> P2
  E3 --> P2
  E4 --> P2
  E5 --> P3

深度延展:模块一交付后,你该如何向组织“证明架构有效”

架构最怕两种极端:一种是 只会画板,没有运行代码;另一种是 只会堆功能,没有边界文档。模块一结束的理想状态,是两者都有最小闭环。下面给出一份“对内沟通”与“对外证明”的清单,帮助你把本讲成果变成组织记忆。

第一,用演示脚本替代口头承诺。准备固定输入:仓库名、变更集、规则评估返回的 findings。演示输出应包括:聚合状态变化、领域事件序列(至少在日志里可见)、以及测试报告。对业务方而言,这比任何架构图更有说服力。

第二,把端口列表当作供应商管理表ReviewRepositoryRepositoryGatewayRuleEvaluatorPort 不仅是代码接口,也是 对外部系统依赖的清单。每个端口旁边应标注:主供应商、备用供应商、SLA、限流策略、密钥归属。CodeSentinel 未来一定会面对模型供应商切换,这张表会救你一命。

第三,把事件清单当作监控与报表的源头ReviewCompletedArchitectureViolationDetected 等事件,最终应映射到指标:审核耗时、违例密度、阻断率、重复违例趋势。若事件只存在于进程内总线,运维视角会失明;因此从架构上要预留 事件外送 的位置(即便现在不做)。

第四,把 ADR 当作 PR 的门槛。凡是改变上下文边界、改变一致性策略、引入新的关键依赖,都应要求补充或更新 ADR。可以轻量,但不能没有。否则 AI 生成代码会在六周内把边界钻穿,而没人知道“为什么当初要这样”。

第五,建立最小“架构守护”流水线。至少做两件事:跑测试;做非法 import 检查。后续再加安全扫描与性能基线。架构师的目标是让“违规变更”在合并前变红,而不是上线后救火。

第六,对 AI 团队特别补充:提示词与领域词典同步更新。当你把 ReviewFailed 引入模型,工具描述、系统提示词、示例对话都要同步,否则模型仍按旧语义生成调用序列。把通用语言当作 跨人类与模型的契约,而不仅是人类之间的契约。

第七,规划模块二的插入点。下一模块你会实现 RuleEvaluatorPort 的多种策略,并把 LLM 调用封装为基础设施。请提前列出:哪些用例步骤允许概率输出,哪些步骤必须确定性(例如权限、计费、幂等)。这张表将决定你如何把“智能”安全地接进来。

第八,技术债登记要诚实。单文件 capstone、进程内事件总线、教学版固定违例,都是刻意的简化。请在 TECH_DEBT.md 或 issue 模板里明确:哪些是为教学牺牲,哪些必须在 MVP 前还清。诚实的技术债登记,比虚假的“完美架构”更专业。

案例推演:把 start_review 拆成两个用例会怎样?

在教学代码里,start_review 同时验证变更集、启动流程、拉取规则 findings、标记架构违例。真实系统更可能拆为 ensure_change_set_accessiblerun_static_rulesrun_llm_review 等多个用例,由编排层组合。拆分的好处是失败模式更清晰、重试策略更细;代价是分布式事务更复杂。架构师要做的是:先画出 一致性边界,再决定拆分粒度。若 findings 写入必须与原审核请求同事务,则它们仍应落在同一工作单元内;若报告生成可异步,则事件外送即可。

评审清单:模块一合并前最后十分钟自检

  • 领域文件是否保持框架无关?
  • 用例是否只依赖端口?
  • 事件是否命名过去时且携带关键标识?
  • 测试是否有负例?
  • ADR 是否记录关键取舍?
  • 运行命令是否在 README 可复制?

与 OpenAPI 的协同:从领域到契约

下一阶段你会为 FastAPI 暴露 API。建议 DTO 与领域对象分离:DTO 管序列化与兼容,领域管规则。不要直接用领域对象当响应模型,除非你明确接受耦合成本。对 AI 工具链而言,OpenAPI schema 往往来自 DTO,这一步是“对外契约”。

给团队负责人的一句话总结

模块一不是写了很多文件,而是建立了 可控变更的坐标系:依赖向内、语义有界、事实可事件化、决策可记录。接下来进入智能实现时,请用这个坐标系约束模型与工程师,否则速度会变成混乱。

练习:把本讲单文件拆成包需要多久?

给自己计时:拆分、import 修正、pytest 全绿。这个练习会让你真实感受分层成本,也会让你相信“边界一旦存在,迁移是机械劳动而不是脑力赌博”。

术语精读:事件风暴与 ADR 关键词

命令:意图,可能失败。领域事件:事实,通常过去式命名。读模型:为查询优化的投影。策略:业务规则或约束。热点:争议大、复杂度高的区域。风险:可能导致延期或事故的点。ADR:架构决策记录,强调背景与后果。替代方案:被明确拒绝的路径。状态:ADR 生命周期(提议、采纳、废弃、被取代)。工作单元:一次业务操作的事务边界。Outbox:保证消息与数据库一致性的模式(后续深入)。掌握这些词,你在组织工作坊时会更像 facilitator 而不是讲解员。

复盘模板:模块一结束写一页复盘

包含:我们达成了什么、我们刻意没做什么、最大的三个风险、下一模块的优先事项。把这页放进仓库 docs/retros/module1.md,三个月后回看会很有价值。

与产品路线的对齐:MVP 应包含哪些领域能力?

建议至少包含:创建审核、启动审核、记录 findings、完成审核、失败可解释。报告美化、复杂规则市场、跨仓分析都可以后置。架构师要与产品一起划清 MVP 边界,否则你会在第一天就把所有上下文全实现,导致没有可验证切片。

给测试工程师的提示:如何从事件表生成用例表

把每个领域事件当作一行用例来源:给定前置状态,当命令发生,则断言事件出现且状态迁移正确。这样测试设计会与模型一致,而不是靠个人灵感。

常见合并冲突:领域模型文件多人修改怎么办?

优先拆文件:按聚合或按上下文分模块;其次建立“领域变更需要架构评审”的规则;最后使用特性分支短周期合并。领域文件是大文件时,Git 冲突会极痛,这是架构拆分的重要动力。

预告衔接:模块二将如何触碰 RuleEvaluatorPort

你会看到静态规则实现、LLM 实现、以及组合策略;并讨论如何为概率输出加 护栏(阈值、拒绝采样、结构化输出)。端口不变,替换的是实现,这正是模块一努力的意义。

收束:模块一完成后建议你立刻做的三件小事

把运行命令录屏三十秒发给团队;把 ADR 链接贴进项目首页;把非法依赖检查 PR 工作流开起来。三件小事的复利很大,因为它们把架构从“个人理解”变成“组织默认”。

延伸:当业务要求“快速改规则”时如何不击穿模型

用规则包版本化与发布流水线满足速度,而不是绕过聚合与事件。快速不等于随意,快速应该来自 自动化与清晰边界,而不是来自 跳过校验

最后提醒:模块实战的终点是“团队能复述”

如果只有你能讲清楚边界,说明知识还没沉淀。用一次内部分享让每位工程师用自己的话复述四个上下文与三个端口,复述通过才算模块一真正收官。

结语:模块一的价值在于“可复制的工程共识”

从骨架到领域,从端口到事件,从测试到 ADR,本质上都在做同一件事:把隐性的架构判断显性化。显性化之后,AI 与人类才能在同一套坐标系里协作;否则协作只是临时默契,迟早会在压力下崩塌。带着这份共识进入模块二,你会更清楚该把智能放在哪些端口之后。下一讲开始,我们将让规则与模型在端口后面“可替换地”竞争。


本讲小结(思维导图)

mindmap
  root((第07讲小结))
    事件风暴
      命令到事件
      异常分支
    建模产出
      聚合根
      值对象
      领域事件
    端口
      ReviewRepository
      RepositoryGateway
      RuleEvaluatorPort
    应用服务
      ReviewAppService
      编排不越权
    事件总线
      同步派发
      可演进消息化
    ADR
      决策可追溯
      后果显式化
    验证
      pytest快速反馈

思考题

  1. start_review 同时做了“启动审核”和“规则评估”,在更复杂场景是否应拆成多个用例?拆分边界如何与事务一致性对齐?
  2. ArchitectureViolationDetectedFindingRecorded 是否可以合并?合并/分离对报告上下文与查询性能分别有什么影响?
  3. 你会把 ADR 存放在仓库的哪个目录?如何让 CI 或 PR 模板强制“涉及边界变更必须更新 ADR”?

下一讲预告(模块二开篇方向)

进入模块二后,我们将把 规则评估端口LLM 调用 结合:在基础设施层实现 RuleEvaluatorPort 的多种策略(纯静态 / 混合 / 全 LLM),并引入 提示词治理、审计日志与成本护栏。你会看到:同样的领域用例不变,但智能实现可热插拔——这就是本模块一路铺垫的终点。