05-模块一-全景认知 第05讲-Clean Architecture 速成 - 依赖倒置与分层边界的本质

0 阅读26分钟

模块一-全景认知 | 第05讲:Clean Architecture 速成 - 依赖倒置与分层边界的本质

开场:分层不是文件夹游戏

如果你做过几年后端工程,一定听过类似口号:“我们要 Clean Architecture / DDD / 六边形架构”。但现实往往是:目录分得很漂亮,依赖却到处乱穿——路由函数里直接 Session.query,用例里直接 httpx.get,领域实体里偷偷 import chromadb。于是架构图停留在 PPT,代码运行在“耦合地狱”。

本讲只做一件事:把 Clean Architecture 的依赖规则 讲透,并把它 映射到 CodeSentinel 的 Python 工程结构。你会理解为什么 依赖只能指向内侧,为什么 接口属于用例层或领域层 而不是基础设施层,以及如何用 Protocol / ABC 在 Python 里实现依赖倒置。

更重要的是,我们会站在 AI 时代 重新解释这套旧理论:大模型的输出是概率性的、不可完全预测的;工程系统必须提供 确定性的外壳(边界、契约、审计、回滚)把智能“关进笼子里”。Clean Architecture 不是洁癖,而是 风险隔离机制——当 AI 生成越界代码时,编译期/测试期/代码审查期能更快抓住它。

本讲后半段会给出 完整可运行 的对比示例:ReviewService 依赖仓储协议(正确方向) versus 直接依赖 SQLAlchemy Session(错误方向)。你会看到同样的业务用例,在可测试性、可替换性、团队协作成本上的巨大差异。

把“开场”写足,是为了避免你把 Clean Architecture 误解成“拆目录”。事实上,它首先是一种 依赖治理策略:谁先 import 谁,谁就拥有更大的自由度;被 import 的一方就被迫承担稳定性义务。CodeSentinel 会把 LLM、向量库、Git 平台 SDK 都视为高变动外层,因此必须让它们依赖内层契约,而不是反过来。你在评审代码时,只要抓住“箭头方向”,就能在大量文件改动中快速判断这次变更是在加固架构,还是在偷偷掏洞。


全局视角:同心圆与依赖流向

Robert C. Martin 用同心圆描述 Clean Architecture:越往里越稳定,越往外越易变。外层可以替换,内层不应感知外层细节。

flowchart TB
  subgraph FW["Frameworks & Drivers(框架与驱动)"]
    direction TB
    F1["FastAPI / Uvicorn"]
    F2["SQLAlchemy / DB"]
    F3["Chroma / HTTP 客户端"]
  end

  subgraph IA["Interface Adapters(接口适配器)"]
    direction TB
    A1["Controllers / Presenters"]
    A2["Gateway 实现"]
    A3["ORM 映射"]
  end

  subgraph UC["Application Business Rules(用例)"]
    direction TB
    U1["ReviewService"]
    U2["RuleEvaluationService"]
  end

  subgraph E["Enterprise Business Rules(实体)"]
    direction TB
    E1["ReviewRequest 等实体规则"]
  end

  FW --> IA
  IA --> UC
  UC --> E

第二张图用 依赖方向箭头 强调规则:源码依赖只能向内。外层实现内层定义的接口(端口),而不是内层 import 外层。

flowchart LR
  subgraph Outer["外层(可替换)"]
    WEB["Web/FastAPI"]
    DB["DB/Vector"]
  end

  subgraph Inner["内层(稳定)"]
    APP["Use Cases"]
    DOM["Entities / Domain"]
  end

  WEB -->|调用| APP
  DB -->|实现端口| APP
  APP --> DOM

  style Inner fill:#e8f5e9,stroke:#2e7d32
  style Outer fill:#fff3e0,stroke:#ef6c00

核心原理:四层模型与依赖倒置

1. Entities(实体层)

实体承载 跨用例不变的业务规则。在 CodeSentinel 中,例如“审核请求必须包含变更标识”“严重级别只能是有限集合”等,应尽量以 纯 Python 表达,不依赖 Web 框架、ORM、向量库。

2. Use Cases(用例层 / 应用层)

用例层描述 系统如何被使用:创建审核、触发规则扫描、生成报告。它们 orchestrate 领域对象,但 不直接操作数据库细节。它们依赖 端口(抽象接口),端口由基础设施实现。

3. Interface Adapters(接口适配器层)

适配器负责 转换:把 HTTP JSON 转成用例输入模型;把用例输出转成 HTTP 响应;把领域对象映射到 ORM;把外部 API DTO 转成领域对象。

4. Frameworks and Drivers(框架与驱动层)

最外层是具体技术:FastAPI、PostgreSQL、Chroma、LangChain、消息队列 SDK。它们允许频繁替换,但 不应渗透进领域规则

5. 依赖规则(The Dependency Rule)

一句话:Source code dependencies must point only inward

这意味着:

  • domain 不 import fastapisqlalchemychromadb
  • application 可以 import domain,但 import infrastructure 要谨慎——更推荐通过 依赖注入presentationmain 组装具体实现。
  • infrastructure 实现 applicationdomain 定义的协议。

6. 依赖倒置(Dependency Inversion)

高层模块不应依赖低层模块,二者都应依赖 抽象。在 Python 里常用:

  • typing.Protocol:结构化子类型,适合“鸭子类型 + 静态检查”。
  • abc.ABC:显式抽象基类,适合需要继承约束的场景。

对 CodeSentinel,ReviewRepository 应定义在 内层(应用层端口或领域仓储接口),PostgreSQL 实现放在 基础设施层

7. 为什么 AI 时代更重要?

AI 编程助手擅长“把功能写出来”,但不擅长自动维护 全局一致性约束。如果没有清晰边界,模型很容易:

  • 在路由里拼接提示词并直接调用 LLM(难以审计与复用);
  • 在实体里写入 I/O(领域层被污染,测试成本飙升);
  • 让基础设施错误“看起来像业务错误”,排障困难。

Clean Architecture 把 概率智能 限制在明确边界(例如 LLMPort 实现类)内,外层用例仍可保持 确定性逻辑:输入校验、权限检查、幂等键、审计日志、回滚策略。

8. 端口(Ports)到底放哪:最容易争论的点

实务上团队常争论:ReviewRepository 属于领域层还是应用层?一个实用原则是:如果仓储抽象表达的是领域持久化语义(聚合根的加载与保存规则),更贴近领域;如果它更偏“应用事务边界与查询用例”,放在应用层端口也可以。关键不在于“名词归属”,而在于 依赖方向是否仍然向内。CodeSentinel 推荐的做法是:先把端口定义在与用例同一层(application/ports),等聚合与不变量稳定后,再把更纯粹的领域仓储概念下沉到 domain/repositories.py(若团队认为有必要)。无论选哪一种,都不要在基础设施层定义端口再让内层依赖——那会把“接口所有权”交给外层,从而颠倒控制面。

9. 适配器层的“薄与厚”:转换逻辑放在哪里

适配器可以薄:只做 DTO 映射;也可以厚:做鉴权、分页、缓存头、错误码翻译。原则是:业务决策不要落在适配器。例如“审核失败是否允许重试”属于用例策略;“HTTP 状态码返回 409 还是 422”可以在适配器层与框架约定。AI 生成代码时最常把两者搅在一起:路由函数里判断业务状态机,这会让测试必须启动 HTTP 层才能验证业务。你要用评审把这类逻辑赶回用例服务。

10. 与六边形架构、洋葱架构的关系:名字不同,箭头相同

你会听到 Hexagonal、Onion、Clean 等不同术语。对架构师而言,不必沉迷宗教战争,而要抽取公约数:核心业务不依赖外部世界;外部通过接口进来;测试用 fake 替换外部。CodeSentinel 选择 Clean 的分层命名,只是为了与后续 DDD 的“领域/用例”语言更好对齐。

11. 用例粒度:一个服务类不是只能有一个方法

ReviewService 可以包含多个用例方法,但要避免把所有 unrelated 流程堆进同一个类导致“上帝服务”。划分依据通常是:同一聚合根的业务流程、或 同一用户目标(用例)集合。CodeSentinel 后续会出现 RuleServiceReportService,不要为了“少文件”强行合并;合并的代价是 PR 冲突与认知过载。

12. 查询与命令分离的早期信号

即便你还没正式引入 CQRS,也要意识到:列表查询与状态变更往往稳定性不同。查询可能需要分页、排序、join;命令需要事务与不变量。把它们混在同一个 Service 方法里有时合理,但有时意味着你忽略了不同的端口需求(例如查询走读优化存储,命令走写模型)。当你发现仓储接口出现大量“只为查询存在”的方法时,就是拆分读模型的信号。

13. 数据一致性与最终一致:分层帮不了你偷懒

Clean Architecture 不自动提供分布式事务。它做的是把 一致性策略 表达清楚:哪些操作必须同事务,哪些可以靠事件补偿。CodeSentinel 的审核链路迟早会遇到异步规则扫描与报告生成,你要在应用层明确“强一致边界在哪里”,否则会把一致性假设隐藏在基础设施细节里。

14. 与类型检查工具协同:Protocol 是“可执行的架构图”

mypy/pyrightProtocol 的支持,使越界依赖更容易在开发期暴露。团队若跳过类型检查,Clean Architecture 的收益会打折扣,因为违反边界的成本又退回到“靠人眼评审”。建议把类型检查纳入 CI,至少覆盖 domainapplication

15. 小结:用一句话记住依赖规则

外层可以知道内层,内层不能知道外层;外层实现内层定义的接口。 把这句话刻在评审文化里,比贴十张架构图更有效;也值得写进团队 Wiki 首页,作为每日可见的约束。


代码实战:ReviewService 的正反例

下面示例为 教学用最小可运行片段(单文件演示)。真实项目应拆分到 domain/application/infrastructure/presentation,此处合并是为让读者一眼看到依赖方向差异。

代码走读:先看清“错在哪里”,再抄“对的那一份”

A 段(错误方向) 的教学目标是让你建立“嗅觉”:BadReviewService 的构造函数直接绑定 FakeSession,这意味着用例层已经知道“数据如何被持久化”。一旦 ORM 模型字段变化,用例必须同步修改;一旦你想把存储换成对象存储加元数据库,用例也要改。这就是典型的 外层细节向内泄漏。更隐蔽的问题是:测试无法在不模拟 Session 行为的情况下验证业务规则,业务测试与持久化测试被强行绑在一起,反馈变慢。AI 生成此类代码时往往非常自信,因为它“能跑”,但架构师要拒绝“能跑即可”。

B 段(正确方向) 的关键在于 ReviewRepository 这个 Protocol:它只描述“我需要保存一个 ReviewRequest”,不关心底层是 PostgreSQL 还是文件。InMemoryReviewRepository 让你在没有 Docker、没有数据库的情况下完成业务测试。把这一点映射到 CodeSentinel:你以后会有 ChromaVectorStorePostgresReviewStore 两个实现,但 ReviewService 不应知道它们存在。GoodReviewService.create_review 里的 ValueError 属于业务校验,它应当在进入仓储之前发生,保证“脏数据不进持久化层”。

C 段(控制器) 展示表现层应薄:ReviewController 只做 DTO 到用例的转发。真实 FastAPI 路由会把 CreateReviewDTO 换成 Pydantic Body 模型,但职责不变。注意控制器不应捕获所有异常并返回 200;错误映射策略应在后续模块结合全局异常处理器设计。这里的集成测试 test_controller_integration 仍然不启动真实 HTTP 服务器,但它验证了 组装是否正确,这是从单元走向组件测试的过渡。

把三段连起来,你应该能用一句话总结:内层定义契约,外层实现细节,测试替换外层。这句话将成为你后续审查所有 AI 生成 PR 的底层判据。

继续走读(对照 CodeSentinel 落地):想象 BadReviewService 被直接接进 FastAPI 路由:你会看到路由里不得不处理 Session 的事务边界、ORM 的 lazy load 异常、以及数据库连接池耗尽时的重试——这些都不是“审核业务”,却占据了评审与排障的主要时间。再想象 GoodReviewService 接进路由:路由只负责鉴权与参数解析,用例负责业务,仓储负责持久化;当你要把审核记录同步写入向量索引时,只需要新增一个 ReviewIndexerPort 的实现类,并在组装点注入,而不是把 Chroma 客户端 import 进用例。再进一步,当你要给审核加审计日志,应在用例层明确记录“业务决策点”,而不是在 ORM hook 里偷偷写日志,否则审计与业务会纠缠不清。

继续走读(测试替身与可替换性)InMemoryReviewRepository 不是“临时凑合”,而是架构的一部分:它证明端口设计合理。若你发现内存实现写起来很痛苦,往往说明端口方法过细或过粗:过细会导致用例充满样板调用;过粗会回到“隐形 Session”。在 CodeSentinel 中,仓储方法通常会围绕聚合根设计 get_by_id/save,而不是暴露 SQL 语义。记住:替身的舒适度,是端口质量的晴雨表。

继续走读(错误与异常的边界)ValueError 在示例里代表业务输入不合法;真实项目可替换为自定义 DomainError 并在表现层映射为 400。基础设施失败(连接超时)不应混用同一异常类型,否则前端无法区分“用户填错”和“系统暂不可用”。AI 生成代码时最爱统一 Exception,你要在评审里要求分层异常策略,哪怕一开始很简单。

继续走读(与下一讲 DDD 的衔接):这里的 ReviewRequest 还是偏贫血的数据结构,下一讲会把它升级为带不变量与事件的聚合根。Clean Architecture 解决“依赖方向”,DDD 解决“语义与一致性”。两者叠加后,GoodReviewService 会更薄:更多规则进入聚合根,用例只编排。不要急,本讲先把方向箭头画对。

A. 错误方向:用例依赖具体基础设施

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

# 假设这是 SQLAlchemy Session(外层细节泄漏进用例)
class FakeSession:
    def add(self, obj: object) -> None:
        self._last = obj

    def commit(self) -> None:
        return


@dataclass
class ReviewRequestORM:
    id: str
    title: str
    created_at: datetime


class BadReviewService:
    """反模式:应用服务直接依赖 Session。"""

    def __init__(self, session: FakeSession) -> None:
        self._session = session

    def create_review(self, title: str) -> dict[str, Any]:
        rid = "rev_bad_1"
        row = ReviewRequestORM(id=rid, title=title, created_at=datetime.now(timezone.utc))
        self._session.add(row)
        self._session.commit()
        return {"id": rid, "title": title}


# 测试被迫关心 ORM 与数据库事务语义
def test_bad_service_smoke():
    svc = BadReviewService(FakeSession())
    assert svc.create_review("demo")["id"]

问题清单:

  • 用例与持久化模型耦合,未来切换存储(文件、向量库元数据表)要改业务代码。
  • 单元测试难以伪造“失败提交”“重复键”等行为,除非继续暴露 Session 细节。

B. 正确方向:用例依赖仓储协议(Protocol)

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Protocol, runtime_checkable


@dataclass(frozen=True)
class ReviewRequest:
    id: str
    title: str
    created_at: datetime

    @staticmethod
    def new(title: str) -> ReviewRequest:
        # 真实系统应使用 UUID7/ULID;教学示例用固定前缀
        rid = f"rev_{abs(hash(title)) % 10_000_000:07d}"
        return ReviewRequest(
            id=rid,
            title=title,
            created_at=datetime.now(timezone.utc),
        )


@runtime_checkable
class ReviewRepository(Protocol):
    def save(self, review: ReviewRequest) -> None: ...


class InMemoryReviewRepository:
    """基础设施实现:内存版,测试与本地开发友好。"""

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

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


class GoodReviewService:
    """用例只依赖端口(Protocol),不知道底层是 SQL 还是内存。"""

    def __init__(self, repo: ReviewRepository) -> None:
        self._repo = repo

    def create_review(self, title: str) -> ReviewRequest:
        if not title.strip():
            raise ValueError("title 不能为空")
        review = ReviewRequest.new(title.strip())
        self._repo.save(review)
        return review


def test_good_service_unit():
    repo = InMemoryReviewRepository()
    svc = GoodReviewService(repo)
    r = svc.create_review(" 架构评审 ")
    assert r.title == "架构评审"
    assert r.id in repo.items

C. 适配器:把“外层 DTO”挡在边界外

from dataclasses import dataclass


@dataclass
class CreateReviewDTO:
    title: str


class ReviewController:
    """表现层:只负责解析输入并调用用例。"""

    def __init__(self, svc: GoodReviewService) -> None:
        self._svc = svc

    def post_create_review(self, body: CreateReviewDTO) -> dict[str, str]:
        review = self._svc.create_review(body.title)
        return {"id": review.id, "title": review.title}


def test_controller_integration():
    repo = InMemoryReviewRepository()
    svc = GoodReviewService(repo)
    ctl = ReviewController(svc)
    resp = ctl.post_create_review(CreateReviewDTO(title="PR-1024"))
    assert resp["id"]

继续走读(如何把示例搬进仓库而不走样):第一步,把 ReviewRequestGoodReviewServiceReviewRepositoryInMemoryReviewRepository 拆到对应包内文件;第二步,在 presentation 增加 FastAPI 路由,把 CreateReviewDTO 换成 Pydantic 模型并加字段校验(例如标题长度、字符集);第三步,在 main.pypresentation/deps.py 组装依赖:生产环境注入 SqlAlchemyReviewRepository,测试注入内存版本。第四步,为 GoodReviewService 写至少三条单测:标题空白、正常创建、仓储被调用一次(可用 spy)。完成四步后,这段教学代码才变成“团队资产”,而不是笔记附录。

继续走读(依赖注入与框架魔法):FastAPI 的 Depends 很强大,但不要让 dependency 函数里出现业务分支。依赖注入层应只做“构造对象图”,业务分支留在用例。否则你会得到“看不见的控制流”,排障时只能打断点逐层跟。CodeSentinel 未来会有租户上下文、模型路由、配额策略,这些横切关注点建议通过显式中间件或 contextvar 传递,而不是散落在 dependency 里。

继续走读(从单体到微服务的端口演化):今天的 Protocol 在同一进程内实现;明天可能变成 gRPC stub,但用例接口可以保持不变,变的只是适配器实现与错误映射。架构师要提前约定:端口方法的失败语义(可重试/不可重试)与超时策略,否则微服务化后第一个爆点就是“错误被吞掉或重复执行”。

继续走读(与 LLM 的关系再强调一次):当你把 LLMPort.summarize_diff 这类方法放进基础设施并实现,用例层仍应保持确定性步骤:先拉取变更、再决定是否需要摘要、再决定如何缓存、最后才把结果写入审核记录。模型可以胡言乱语,但步骤不能乱序;顺序与幂等属于用例责任,不属于模型责任。

继续走读(性能与分层无关但必须可见):依赖倒置不会自动让你更快;性能优化要在端口实现里完成(批量查询、缓存、异步 I/O)。分层的价值是让优化局部化:你可以替换 ReviewRepository 实现而不触动业务规则。反之,若性能优化迫使你在用例里写 SQL,说明端口抽象可能需要增加“批量加载”之类的方法,而不是破坏分层。

继续走读(文档与代码同步):当你引入新端口时,同步更新一页“端口目录”文档:方法签名、前置条件、失败语义、典型实现类。否则新同学会靠猜来注入依赖,AI 更会靠概率生成调用。文档越轻量越好,但必须与代码同源演进,避免文档成为谎言,这是专业工程团队的基本自我要求。

映射到 CodeSentinel 目录(推荐落位)

  • domain/ReviewRequest(实体/聚合根的起点,后续讲次扩展)
  • application/GoodReviewService、端口 ReviewRepository
  • infrastructure/SqlAlchemyReviewRepositoryChromaMetadataRepository(示例名)
  • presentation/ReviewController 或 FastAPI 路由函数(薄)

生产环境实战:团队落地与评审清单

1. 架构守护:比“约定”更可靠的是“可自动检查”

生产团队常用:

  • import-linter 或自定义脚本检查非法 import(例如 domain 不得引用 fastapi)。
  • ruff/mypy 配合 Protocol,让越界依赖在 CI 阶段失败。

2. 代码评审问句(建议写进评审模板)

  • 这条改动是否让 内层 import 了 外层
  • 用例是否直接操作了 SDK 客户端
  • 失败模式是否清晰:业务失败 vs 基础设施失败

3. AI 生成代码的反模式雷达

当 Copilot/Agent 产出以下模式,要高度警惕:

  • entity 文件中出现 requests.postchromadb.Client
  • 在路由函数里拼装长提示词并直接调用模型(缺少端口与审计)。
  • 把“数据库事务边界”散落到多个层,导致无法推理一致性。

下面用 序列图 描述“正确边界下”一次创建审核请求的路径:表现层薄、用例编排、仓储持久化,LLM 端口未来可插入在用例步骤之间而不破坏领域规则。

sequenceDiagram
  participant R as FastAPI Route
  participant U as ReviewService (Use Case)
  participant D as Domain (ReviewRequest)
  participant P as ReviewRepository
  participant L as LLMPort (未来接入)

  R->>U: create_review(title)
  U->>D: ReviewRequest.new(title)
  U->>P: save(review)
  P-->>U: ok
  U-->>R: review
  Note over U,L: 后续可在用例中显式调用 L,而不是在路由里调用

深度延展:把依赖规则变成“可执行的团队纪律”

这一节讨论的是落地方法:Clean Architecture 如果只停留在 README,会在三个星期内被穿透。你需要的是 可执行的纪律,而不是口号。

第一,把非法依赖变成 CI 失败。Python 没有 Java 那样强的模块边界,因此更需要工具。import-linter 可以声明契约:codesentinel.domain 不得导入 fastapisqlalchemychromadb 等。你也可以写一个简单的 AST 扫描脚本,在 CI 中对 domain/**/*.py 做关键字黑名单检查。架构师要记得:规则要“可解释”,否则团队会把它当成官僚主义。每条规则都应对应一次真实事故或真实风险(例如领域层引入网络 I/O 导致测试变慢)。

第二,依赖注入的位置要稳定。推荐在 presentation 的组装层(或 main.py 的工厂)创建具体实现,把它们注入用例服务。避免在领域对象内部“偷偷读取全局单例”。全局单例对 AI 生成代码特别友好,因此也特别容易泛滥;你要用评审明确禁止,除非有极少数性能理由并写入 ADR。

第三,错误类型分层:BusinessError vs InfraError。生产排障时,最浪费时间的是“数据库超时被当成业务校验失败返回给前端”。在边界上明确异常映射策略:基础设施异常在用例层捕获并转换为 有意识的失败(可重试/不可重试),不要一路冒泡到路由层才判断。CodeSentinel 未来会面对模型供应商限流、向量库超时、Git API 429 等,如果不在架构层先约定,你会得到一堆无法聚合监控的 500。

第四,测试金字塔与端口。有了 Protocol,单元测试应主要覆盖用例与领域:用内存 fake 替换仓储与外部网关。集成测试再挑关键路径验证真实 DB/SQL。AI 团队往往反过来:一上来就写大量端到端,导致反馈慢、定位难。把依赖倒置做好,是在为更快的反馈买单。

第五,渐进式重构策略。老项目迁移时,不要幻想“停业务两个月重写”。正确路径通常是:先为新功能建立端口与新用例,再逐步把最热路径从“Session 泄漏”迁回边界内。CodeSentinel 作为新课项目可以从第一天做对,但你在真实工作中会更常遇到渐进迁移,因此要记住:先封住增量,再消化存量

第六,对 AI 生成代码的审查清单(增强版)。除了前文提到的 SDK 入侵,还要关注:是否在 __init__.py 里做副作用;是否在模块顶层创建客户端;是否把异步与同步混用导致事件循环阻塞;是否把提示词模板散落在多个层导致无法版本化。架构师的价值,是把这些问题从“个人经验”提升为“团队默认检查项”。

第七,与 DDD 的衔接预告。下一讲开始我们会引入限界上下文与聚合,它们解决“语义边界”,Clean Architecture 解决“依赖方向”。两者叠加时,最常见误区是:把 DDD 的聚合直接绑到 ORM 模型上,导致领域被数据库 schema 统治。记住:ORM 是基础设施细节,领域对象应优先表达业务语言,再由 mapper 翻译。

案例推演:CodeSentinel 中一次典型的“依赖方向事故”

假设某同行为加快需求,把 chromadb 的客户端创建放进了 domain/chroma_tools.py,并在 ReviewRequest 里直接调用相似度查询来“找历史相似 PR”。第一天功能上线,第二天测试开始变慢,第三天发现领域层无法在无网络环境运行。更糟的是,向量维度和集合配置变化会导致领域规则悄悄改变——这不是业务变更,而是基础设施变更在越权驱动业务。正确修复路径是:定义 SimilarReviewPort,由基础设施实现向量检索;用例层决定何时查询、如何处理空结果;领域层只保留“相似度阈值是否合理”这类纯规则。架构评审里,把“领域文件 import 了第三方 SDK”定为 一票否决 并不夸张。

反模式画廊:五种常见“分层 cosmetic”

一、目录分层但 import 全联通:没有工具约束,靠自觉。二、把 Service 当成万能类:几千行代码,所有外部调用都进来。三、DTO 满天飞但没有映射边界:领域、持久化、API 三套模型混用。四、测试只测 mock 不测行为:mock 过度导致测试永远绿。五、把日志当架构:靠 print 追踪调用链,却没有边界。Clean Architecture 的目标是让系统“可推理”,而不是让 PPT 好看。

与 FastAPI 依赖注入(Depends)的协同建议

FastAPI 的 Depends 很适合组装适配器与用例服务,但要避免把重逻辑写进 dependency 函数本身。推荐 dependency 只做 构造与缓存(例如每请求一个 DB session),业务决策仍在用例服务。对于 CodeSentinel,后续可能出现“按租户选择模型供应商”的逻辑,这种分支属于应用层策略,而不是路由装饰器里的 if-else 堆叠。

团队讨论题:严格分层会不会拖慢 AI 迭代?

短期可能略慢,因为要多写端口与 fake;中期会显著加快,因为测试快、替换快、合并冲突少。可以用数据说话:统计引入端口前后,修复一个集成缺陷的平均时间。若团队只追求“当天合并”,不追求“下周还能合并”,确实可能觉得分层碍事——这时要回到产品风险:CodeSentinel 一旦审核结果不可靠,成本远高于多写两个接口。

给架构师的沟通话术:如何说服业务方

业务方通常关心交付速度与稳定性。你可以把依赖规则翻译为:我们让易变的东西可替换,让稳定的东西可测试,从而减少线上事故与返工。不要讲同心圆,讲“换模型供应商不影响审核规则”。

练习建议:用一天时间做一次依赖审计

随机抽取十个 PR,统计:领域层文件是否出现网络/ORM/向量库 import;路由函数是否出现 SQL;用例是否直接构造 SDK 客户端。把结果贴到团队看板,比任何培训都直观。

术语精读:Clean Architecture 关键词对照表

实体(Entities):跨用例稳定的核心业务规则载体。用例(Use Cases):描述系统如何被使用、编排领域与端口的交互单元。接口适配器(Interface Adapters):在框架与用例之间做转换的层。框架与驱动(Frameworks and Drivers):具体技术实现与外部系统。依赖规则:源码依赖只能指向更内层。依赖倒置:高层与低层都依赖抽象,由外层实现内层接口。端口(Port):内层定义的交互契约。适配器(Adapter):外层对端口的实现。注入(Injection):在组装点把实现传给需要抽象的地方。测试替身(Test Double):假对象替代外部依赖以加速测试。把这张表贴在评审指南里,新人能更快对齐语言。

对照阅读:当 AI 建议“直接把 SDK 放进 Service”时你怎么回?

你可以要求它给出三层答案:SDK 属于哪一层?端口接口签名是什么?如何用 fake 覆盖核心路径?如果答不上来,就不要合并。架构师不是反对 SDK,而是反对 SDK 越权

扩展阅读路线(不增加依赖的前提下)

建议团队集体阅读并讨论:依赖倒置与测试替身的案例;FastAPI 大型项目的分层样例;以及你们公司内部的“服务间调用规范”。把外部资料映射到 CodeSentinel 的目录名上,而不是停留在抽象讨论。

里程碑:什么时候算“分层真正落地”?

不是目录存在,而是 越界 PR 会被 CI 拒绝;不是写了 Protocol,而是 核心用例能在无 DB 情况下测完;不是画了图,而是 新同学能在一天内按图找到该改的文件。用这三条验收,Clean Architecture 才算从 PPT 走进工程。

收束:用一张检查表结束本讲(建议打印)

检查项包括:用例是否只依赖端口;端口是否定义在内层;适配器是否只做转换与 IO;领域是否零框架 import;测试是否能在内存替身下运行;异常是否分层映射;以及 AI 生成代码是否经过越界扫描。每周例会花五分钟过一遍表,比每月一次架构分享更能阻止腐化。

延伸:多仓库/多服务时 Clean Architecture 还适用吗?

适用,但要升级到 系统级边界:服务之间也有依赖方向与契约版本。CodeSentinel 未来可能是单体起步、服务化演进;本讲规则在单体里练熟,未来拆分时你会自然把端口变 RPC、把事件变消息,而不是手足无措。

最后提醒:不要为了“纯粹”牺牲可观测性

分层与日志追踪并不冲突。你可以在适配器记录外部调用耗时,在用例记录业务决策点,在领域保持无日志或极少日志。关键是 不要把观测细节写进业务规则,而是把观测作为横切能力挂在边界上。

结语:Clean Architecture 是一种“降低认知负载”的工程策略

当系统变大,最难的不是写代码,而是 让团队在有限工作记忆里推理系统行为。依赖方向清晰、端口稳定、测试快速,都是在降低认知负载。CodeSentinel 引入 AI 后,人类工程师的认知负载会被模型输出放大;因此本讲的规则不是教条,而是对抗复杂度的现实工具。把这句话写进团队共识:架构首先是让人脑与机器都能理解的结构。坚持这一点,Clean Architecture 就不会沦为形式主义。


本讲小结(思维导图)

mindmap
  root((第05讲小结))
    依赖规则
      只能向内
      内层稳定
    四层
      Entities
      Use Cases
      Adapters
      Frameworks
    Python落地
      Protocol端口
      内存仓储测试
    CodeSentinel映射
      domain纯规则
      application编排
      infra实现
      presentation薄
    AI时代意义
      概率智能隔离
      确定性外壳
    反模式
      Session泄漏
      SDK进领域

思考题

  1. ProtocolABC 在 Clean Architecture 项目里如何选择?各自最大的坑是什么(例如 @runtime_checkable 的性能与语义)?
  2. 如果 ReviewService 需要发布领域事件到消息队列,事件总线接口应定义在哪一层?消息队列 SDK 应出现在哪一层?
  3. 你会如何在 CI 中检测“非法依赖方向”,避免团队慢慢把分层腐蚀掉?

下一讲预告

下一讲进入 DDD 核心:限界上下文、聚合根、领域事件与通用语言。我们会把这些概念 显式映射 到 CodeSentinel:ReviewContextRuleContextRepositoryContextReportContext,并用 dataclasses 写出可读的领域模型,为后续 LangChain 编排打下语义基础。


延伸阅读(与本讲强相关)

  • Clean Architecture 的依赖规则与“尖叫架构”(Screaming Architecture)在微服务边界上的协同。
  • Python 中 typing.Protocol 与结构子类型如何减少继承深度。
  • 将 LLM 调用视为 基础设施端口 时,提示词模板与会话缓存应如何分层治理(后续模块会深入)。