DDD 六条铁律:让 CI 替你骂人——go-arch-lint 门禁实战(第104篇)

第二季第 4 篇。拆解对象:DeepFlux 仓库里一道 264 行的架构检查配置(server/.go-arch-lint.yml)和它的 CI 门禁。这篇不要求你懂 Go 或 DDD,用到的概念当场讲,所有的"我跑过"都带 2026-09-04 的时间戳。

先交代真实状态。今天(2026-09-04)我在仓库根跑了一条命令:

bash 复制代码
$ make ddd-check
module: github.com/deepflux-cn/deepflux
linters:
   On | Base: component imports   # always on
  Off | Advanced: vendor imports  # 开关在配置里,未开
  Off | Advanced: method calls and dependency injections  # deepScan 未开
OK - No warnings found

输出只有一行绿的 OK - No warnings found。但为了让你知道这一行绿值多少钱,得先把背景讲一下:这个"OK"背后是六条铁律,而这六条铁律在几个月里经历过"写在文档里 → 被人违反 → 机器强制"的三段式进化,中间还有过一次真实的"假绿"事故。

一、先弄懂三个词

DDD(领域驱动设计) :一种组织代码的方法,核心观点是"代码的结构应该匹配业务的结构"。订单相关的代码放一起,库存相关的放一起,两家之间别乱串门。它不像"代码必须缩进 4 空格"那样能被编译器检查------它一开始只是写作风格约定,靠人自觉。

限界上下文(Bounded Context,下文简称 BC):DDD 里的"业务子域"。一个订单系统可能有订单、库存、客户、物流四个 BC,各自有各自的模型和规则。上面说的"别乱串门",串门闹得最凶的就是 BC 之间。

架构检查器(go-arch-lint) :一个 Go 命令行工具,输入一份 YAML 配置("哪些目录属于哪个组件、谁允许依赖谁"),输出"谁违反了依赖规则"。它读的是源码的 import 语句------每个文件开头那一排 import 就是它的证据。所以它检查的是"编译期可检查的红线":过不了这一关,代码照样能编译、能跑,但架构已经走样了。

打个比方:DDD 像小区规矩("邻居之间不走私货"),go-arch-lint 像小区的门卫(每个快递箱都过一遍,该谁家的放谁家)。规矩靠人记总会忘,门卫记性不会忘------这就是本篇的主题:把铁律交给机器,而不是交给自觉

二、六条铁律:谁在守它们

仓库里的架构文档(docs/architecture/ddd-bounded-contexts.md §3)列了一张 D 规则表,标题叫"D-rules(6 条编译期可检查的红线)"。原文如下,我按"当前实际由什么守"分了三档:

# 铁律 内容 本文档标注的检查方式 实际由什么守(2026-09-04 核实)
D1 domain/ 只能依赖 domain/ 自己 + 标准库 领域层不碰外部框架、不碰别的层 go-arch-lint go-arch-lint(机器)
D2 application/ 不能 import interfaces/ 应用层不知道 HTTP/gRPC 的存在 go-arch-lint go-arch-lint(机器)
D3 聚合根之间通过 ID 引用 · 不持有彼此指针 领域对象不互相持有对方对象 code review · 模板示例 模板 + 评审(人)
D4 跨 BC 协作只走 gRPC + ACL BC 之间不能 import 对方的代码包 grep + 评审 go-arch-lint(机器)
D5 Repository 接口写 domain · 实现写 infrastructure 数据存取接口与实现分层存放 模板 模板 + 评审(人)
D6 领域事件先写 outbox 业务变更与投递事件同事务落库 code review 集成测试(机器)

几个如实说明,这篇的分量就在这些"如实"里:

1. "D4 靠 grep + 评审"这句已经是历史了。 文档表格里 D4 那格写着"grep + 评审",但现状是 go-arch-lint 的配置里每个 BC 的 deps 白名单都不含其它 BC------跨 BC 的 import 一出现,工具直接报违规。文档滞后于实现,这是本篇要反复强调的现象。

2. D6 那格写着"code review",实际已有集成测试。 2026-06-02 起,仓库里有一组专门验证 D6 的集成测试(server/tests/d6atomicity),方法是"负向验证":临时给 events_outbox 表加一个 CHECK (false) 约束(每次插入必失败),然后调用各 BC 的事务方法,断言聚合行的落库也被回滚------事件写不进去,业务数据也不许留下。这节下面的"五"里我跑了一遍,4 个用例全过。文档表格没更新(检查方式栏还是"code review"),但机器已经在守。

3. "编译期可检查"这个措辞有水分。 D3、D5 至今没有机器门------"聚合根之间只通过 ID 引用"这种规则,import 图里看不出来(两个对象都在同一个 package 里,文件间可能根本不互相 import)。所以六条铁律的真实格局是:3 条机器守(D1/D2/D4 import 红线)、2 条人守(D3/D5 模板)、1 条测试守(D6)。这是一句比"六条编译期可检查的红线"更准确的话。

三、机器是怎么守的:264 行配置的解剖

门卫的"记性"放在 server/.go-arch-lint.yml(264 行,2026-05-30 引入,commit 消息原话"SSRF guard, billing RLS, per-BC arch-lint, coverage"------彼时就是冲着"per-BC"去的)。它的核心结构只有两个部件:

部件一:组件(components)------给每个 BC 的四层各起一个名字,用目录通配符圈地。

yaml 复制代码
agent_domain:          { in: internal/agent/domain/** }
agent_application:     { in: internal/agent/application/** }
agent_infrastructure:  { in: internal/agent/infrastructure/** }
agent_interfaces:      { in: internal/agent/interfaces/** }

15 个 BC(agent/attachment/data/audit/auth/hook/kb/memory/skill/tenant/tool/sop/orchestration/marketingcode/contenthub,audit/hook/tool/skill 在 2026-06-09 合并进 agent 统一体但仍各自建模)× 4 层,共 60 个层组件,再加共享组件、内核助手(kernels)、适配器豁免与横切组件(x_*),合计 87 个组件条目。注意这比"一租户一目录"的简单版难得多------每个 BC 的内部 layering 是独立建模的 ,agent 的 domain 允许依赖什么,与 kb 的 domain 允许依赖什么,是两行独立配置。文档 §3 里的示例配置(全局只有一个 domain 组件)是教学版;生产版早已 per-BC 化。

部件二:依赖白名单(deps)------每层"只允许依赖这些组件",没写的就是禁地。

yaml 复制代码
agent_domain:          { mayDependOn: [agent_domain, pkg] }
agent_application:     { mayDependOn: [agent_application, agent_domain, pkg, sharedinfra, x_observability] }

读法:agent_domain 只允许依赖「自己(同目录其它文件)+ pkg(共享包)」;agent_application 在自己的层之外只能碰 domain、pkg、sharedinfra(LLM/存储/MCP 等共享基础设施)、x_observability(日志指标)。

这套白名单制有个先天的好设计:它不是审计员,是法官------任何没写进白名单的依赖出现,直接判违规,不需要配置方证明"这个 import 有问题",只需要证明"这不在清单里"。判断题变成了集合题。

第三个部件是"刻意豁免"清单,这是整份配置最有味道的地方。 配置里有一批组件是"特殊身份":

yaml 复制代码
# 刻意豁免:hooklocal 是单体 df_server 模式下的进程内 hook 适配器,
# 有意复用 hook BC builtin handlers(见 runner.go 文件头);Sprint 2+ 改 gRPC。
# 仅此目录可依赖 hook BC,其余 agent_infrastructure 仍受 D4 约束。
agent_hooklocal: { in: internal/agent/infrastructure/hooklocal/** }

类似的还有 agent_hookdispatch(进程内 hook 分发)、agent_skilladapter(进程内技能适配)、agent_toollocal(进程内工具适配)、auth_localauth(bcrypt 口令哈希助手)。它们都是"合并 BC"时代(2026-06-09 几个 BC 合并进 agent 单体)留下的进程内适配层------正式版走 gRPC,单体模式先走函数直调,所以它们被特别放行"可以依赖另一个 BC"。

这张豁免清单的价值在边界 :到底允不允许跨 BC 协同?答案不是"一票否决",而是"只有这几条走廊允许,每条走廊都有注释说明为什么"。架构规则一旦可以全部豁免,它就是一纸空文;但一张"无豁免"的规则表,又会在单体模式逼出"绕开规则"的脏活。豁免清单本身就是规则的组成部分。

还有几类"非 BC 组件":pkg(共享包,idgen 等)、sharedinfra(embedding/llm/prompt/storage/mcp,注释标明"mcp 红线#3 强制")、generated(gRPC 生成码,D4 的唯一合法跨 BC 通道)、以及 cmd 和一堆 x_*(gateway/observability/billing/uiserve 等横切关注点)------横切组件标了 anyProjectDeps: true:随便依赖谁,因为它们本来就负责把大家接起来。

四、门卫抓到过的真问题:五个案底

配置不是一次写对的。按时间顺序,门卫和它的管理员都摔过跤,每一跤都有一个 commit 编号可查:

案底① 幽灵 BC(2026-06-21,e155af9e :配置里声明了 license_*llmgateway_*x_security 三个组件,但 internal/ 下根本没有对应目录------go-arch-lint 对不存在的目录直接报 not found。commit 消息原话:"删除幽灵 BC 组件",修复后本地输出"OK - No warnings found"。配置撒谎(声明了不存在的 BC)跑不掉,这是工具给的第一课:声明即承诺。

案底② 18 条 notice(2026-07-02,45d73af6 :新引入的 sop BC 没有注册进 archfile,它下面的文件不被任何组件圈住,导致 13 条 not-attached("这堆文件归谁管?")通知;加上 agent_skilladapter/agent_toollocal 的 5 条依赖通知,共 18 条。修复动作就是两件事:把 sop 四层注册进配置;给两个适配器按 hooklocal 的先例建"窄组件"(只放行该目录依赖 skill/tool BC)。新 BC 忘了登记自己------门卫一清点就发现少人了。

案底③ 假绿事故(2026-07-21,b745303b,这是全篇最值得细读的一个 commit) :commit 消息第一句:"发现 make ddd-check 本地空操作(go-arch-lint 未装静默跳过),前序'全绿'实为空过;本次装 go-arch-lint 后 ddd-check 首次真正 exit=0 "。注意时间差:门禁 2026-05-30 装好,到 07-21 才发现本地这道门根本没在把关 ------工具没装,make ddd-check 一句"工具未安装"就退出了,退出码还是 0(成功)。"全绿"是从没跑过的绿。同批修复还碰到 kb 的 5 个叶子助手包(chunking/tokenize/citation/filecheck/kernel)未注册,14 条 notice 归零。

案底④ 门卫自查手册(2026-07-31,Makefile 注释) :code-review 发现本地 make lint / make ddd-check 的写法是 A && B || echo "未安装"------这个模式有个致命属性:B 失败时也走 || 分支 ,于是"有 lint 错误"被打印成"工具没装",退出码还是 0。一整个会话都以为本地装不了工具,实际上它在报错。另发现 command -v 找不到装在 $(go env GOPATH)/bin 里的工具(go install 的默认落点不在 PATH)。修复:改成单行 if/else,且检查 GOBIN 目录。

案底⑤ CI 侧的隐藏链(2026-08-11,ci.yml 注释) :CI 里装 go-arch-lint 的 step 原来在 server/(模块根)里跑 go install------这会把工具及其依赖的 hash 追加进 server/go.sum,破坏 release 前置的指纹校验(go.sum 被污染 → 指纹红 → Lint + migration 任务长期失败)。修复:(cd /tmp && go install ...@v1.18.0) 在隔离目录安装,并钉死版本 v1.18.0(注释:"D 规则判定逻辑在工具里,随版漂移同理"------版本钉住,铁律才是铁律)。

五个案底合起来是同一课:一道门值不值钱,取决于它有没有被"失控时静默"的路径 。工具没装=静默通过(案底③④),门引用的路径不存在=静默成功(案底①),门装在有毒的环境=把别的好门拖红(案底⑤)。所以这个仓库后来加了"审计门的门":deploy/scripts/audit-ci-refs.sh(幽灵门清账,防"门被 label 遮蔽后引用失效")和 Gate Registry 配对("本仓一共有几道门、各守什么"登记成表)。

五、我今天跑了三个实验

写这篇之前,我在同一台机器上做了三件事,都留下原始输出:

实验一(真绿)make ddd-checkOK - No warnings found。注意门卫这版是走 $(go env GOPATH)/bin 找的(案底④的修复生效了),工具版本 v1.18.0 与 CI 钉版一致。

实验二(受控违规) :我把配置复制到 /tmp/arch-tight-d1.yml,把 agent_domain 的白名单从 [agent_domain, pkg] 收紧成 [agent_domain](不许再用共享包),然后跑检查------收紧的是规则(不是代码),源代码零改动:

swift 复制代码
Component agent_domain shouldn't depend on github.com/deepflux/deepflux/internal/pkg/idgen in .../internal/agent/domain/model/interrupt.go:7
Component agent_domain shouldn't depend on github.com/deepflux/deepflux/internal/pkg/idgen in .../internal/agent/domain/model/message.go:7
Component agent_domain shouldn't depend on github.com/deepflux/deepflux/internal/pkg/idgen in .../internal/agent/domain/model/session.go:9

--
total notices: 3

这就是一门"真"门的样子:违规清单精确到文件+行号+完整 import 的包路径,3 处,不多不少。同时也演示了"白名单允许共享组件"的必要性------domain 用 UUID 生成器(idgen)是合法依赖,配置不写白名单它就被误判。这也是"零外部 import"在实践中的取舍(第五节细说)。

实验三(D6 集成测试)go test -tags=integration ./tests/d6atomicity/,连上本机 PostgreSQL 18.4 的 deepflux_test 库:

diff 复制代码
--- PASS: TestD6_Auth_PersistTx_AtomicRollback (1.74s)
--- PASS: TestD6_KB_DocumentSaveTx_AtomicRollback (0.36s)
--- PASS: TestD6_Memory_SaveTx_AtomicRollback (0.95s)
--- PASS: TestD6_Tenant_UpgradePlan_AtomicRollback (0.85s)
PASS
ok  github.com/deepflux/deepflux/tests/d6atomicity  4.181s

4 秒跑完,4 个 BC(认证/知识库/记忆/租户)的用例全绿。这里要强调它是负向验证 ------不是"跑通了就是对的",而是"故意让事件写入失败,断言业务数据也被回滚"。sabotageOutboxevents_outboxCHECK (false) NOT VALIDNOT VALID 关键词是关键:跳过存量行校验,只卡新写入),跑完每个用例 healOutbox 删掉约束还给数据库原样。顺手一提:这组测试是 2026-06-02 落地的,与第 103 篇那场"贫血→充血"重构是同一天------那天还有一条整改线在悄悄收尾(D6 事务化 + 三处 schema↔模型契约修复)。

六、机器守不了的,以及文档漂移

门卫只守 import。以下这些是诚实记录"哪些规则其实没被机器守":

1. D1 的"零外部 import"字面已破。 docs/DESIGN.md 里写编排 BC 的控制面原则是"D1:零外部 import, Eino 类型绝不进 domain "。但配置里所有 domain 组件都标着 anyVendorDeps: true------vendor(第三方库)依赖全局放行,domain 里早年就引进了 github.com/google/uuid(值对象生成器)。所以机器的真实语义是"不依赖本仓库其它 BC/其它层 ",不是"零外部依赖"。Eino(编排引擎)确实没有进 domain------它连一个 import 都没有,痕迹只出现在注释里("einoadapter 会把 . sanitize 成 _"这种说明文字)------但那是代码评审和模板守出来的,不是门卫守的。区别很大:门卫守的规则坏了会被拦住,人守的规则坏了只能靠下一次 review 看见。

2. D3/D5 仍无人守。 "聚合根之间只持 ID 指针""Repository 接口在 domain、实现在 infrastructure",这两个无论怎么配 YAML 都拦不住(同一个 BC 的文件之间不 import 也会违反前者)。它们靠"起手式拷模板"(文档 §10 给了新 BC 的骨架)和 code review。

3. 文档漂移(这节的"超诚实"部分)。 我核对了三处不一致,都如实列出:

  • docs/architecture/ddd-bounded-contexts.md 表首注释说"红线见 CLAUDE.md §2 D1-D6 ",但 CLAUDE.md 在 2026-07-18 被重写(430 行 → 5 行),§2 已不存在------引用悬空(好在 §3 自带完整表格,内容没丢)。
  • 上表的检查方式栏:D4 写"grep + 评审"(实际是 go-arch-lint 机器守)、D6 写"code review"(实际 6 月已有集成测试)。
  • 连我这篇的规划条目里都有一处修正:规划时写"tests/d6atomicity 五个包",实际是一个包 (package d6atomicity)里的 5 个测试文件、4 个 BC 用例(atomicity_test.go 是注入基础设施,不出用例)。

文档滞后是常态,但这正是"机器铁律"与"文档铁律"的分水岭:文档错了会在某天误导一个新手,机器错了会在 CI 上立刻拦下一次违规。

小结:门禁的价值不在守住,在于"让违规变贵"

  1. 白名单制是最省心的架构门。 不用枚举所有违规形态,只要写清"谁允许依赖谁",其余一概不许------配置即法律,豁免清单即司法解释,每条豁免都带理由注释。
  2. 机器守的 3 条 + 测试守的 1 条 + 人守的 2 条,是真实现状。 "六条编译期可检查的红线"这句话宣传价值高于准确度;准确的版本是"其中 4 条有了机器兜底,2 条仍靠人"。
  3. 门本身是需要被审计的。 假绿事故(工具没装、静默通过)、幽灵 BC(声明了不存在的目录)、go.sum 污染(安装位置不对)、规则版本漂移(不钉版)------四次案底全是被门自己"静默"出来的。所以这仓库最终配了"审计门的门":幽灵门清账脚本 + Gate Registry 配对表。让 CI 替你骂人之前,先保证 CI 不会替你装聋。
相关推荐
花椒技术1 小时前
客服Agent:一个已交付 Agent 的工程实现拆解
agent·ai编程·产品
算法大模型备案干货咪2 小时前
《把内容安全做成CI卡点:AIGC合规的工程化落地》
安全·ci/cd·aigc
johnny2333 小时前
腾讯开源TeamAI-CLI:简介、原理、实战
agent·cli
武子康3 小时前
转写完全正确,语音 Agent 为什么还是做错了决定
人工智能·llm·agent
wangfpp3 小时前
原生NodeJS维护Agent Memory实践
后端·agent·全栈
人才瘾大3 小时前
写了几十个Skill之后,我总结出这套工程方法:从「触发不了」到「生产可用」
agent
用户976104399213 小时前
第三章 大语言模型基础 3.1语言模型与Transformer架构
agent
moMo3 小时前
Workflow 与 Agent:AI 应用的两大范式
agent·workflow