每个领域:内部 DDD 分层 + 一层薄的对外 facade
问题(2026-07-27,owner)
internal/<domain>/ 下的各个领域没有对外暴露的门面层。要使用一个领域,你得把整个目录读一遍才能找到它的方法——没有一个单一的地方能说清楚"这个领域提供了什么"。领域内部的文件也没有按 DDD 角色分类(entity / usecase / service / repo / infra 混在一起)。
决定
每个 internal/<domain>/ 都同时获得两样东西:
1. 一层薄的对外 facade——一眼看清协议的唯一地方
具体形态(2026-07-27 定下): facade 是自己独立的子包 internal/<domain>/facade/。
facade/包是该领域唯一允许外部代码导入的目标。它是一层薄的重导出 + codedoc 外壳:从内部实现里提升上来的值类型、构造函数和 port,仅此而已。- 包名 = 领域名,而不是目录名。 目录始终叫
facade/(这样"这是入口"一眼可辨),但 package 声明是package <domain>,所以调用处写的是security.Verifier,而不是facade.Verifier(后者在跨领域时会冲突/需要别名)。一条针对性的 revive 豁免规则(internal/*/facade/的package-directory-mismatch)覆盖了这一点。 - 薄——只有协议,没有别的。 没有业务逻辑;它把工作委托给内部的 usecase/service。
- codedoc 是强制的,而且是承重的:包文档列出整个协议;每个符号都有一条说明其契约的文档注释。只读
facade/就能知道这个领域暴露了什么。 - 扫一眼
facade/就等于看到该领域的整个 API。这就是验收标准。
实例——security(探路者,已完成):
internal/security/
├─ facade/ package security — facade.go (IP-ban) + facade_captcha.go (captcha) + facade_ops.go + doc
├─ ops/ package ops — the domain's fp.Op declarations (ip_bans.go), aggregated by the dispatcher
├─ ban/ package ban — guts: BannedIP entity + repo
├─ captcha/ package captcha — guts: Verifier + Turnstile impl
└─ db/ package db — guts: sqlc DAO (domain owns its repository)
外部代码只导入 .../internal/security/facade,仅此一条路。(ops/ 是随 dispatcher 收口一起来的,53733b661,2026-08-01 —— 见 convergence-inbound-and-outbound。)
2. 内部 DDD 分层——内部实现作为同级子包,按角色分类
内部实现是 facade/ 的同级子包(不是嵌套在 Go 的 internal/ 之下;边界靠 lint 强制执行,见下文)。按标准 DDD 角色分类:
- entity——领域的值对象/聚合(名词)。
- usecase——应用流程编排(该领域自己的 use case 留在这里,而不是放进一个共享的
usecases大杂烩)。 - service——领域服务(不属于某个 entity 方法的逻辑)。
- repository / db——该领域的持久化;按 backend-domain-modules,领域拥有自己的 sqlc DAO(
db/);infra根目录只保留不属于任何领域的基础设施。
小领域可以把角色合并(security 没有 usecase/service 层——每个能力只有 entity+repo)。只有当一个角色真的撑得起一个文件时,才把它拆出来。
标准 DDD——没有花活。要点在于:读者按角色就能落到正确的文件,而 facade 就是入口。
为什么
- 消灭了"每次都要读完整个领域"——facade + codedoc 就是契约。
- 让模块边界变得真实:外部代码依赖的是 facade,而不是散落的内部实现,所以重构内部实现不会波及外部。
- 为 facade-parity / owner-facade-from-registry 打基础:每个领域干净的 facade,正是生成的对外 facade 用来校验的基准。
强制执行(lint)
check-domain-facade-boundary.sh(在 make lint 里):一个领域一旦长出 facade/ 子目录,就算自动加入约束;从那时起,该领域外部的任何包如果导入了它的非 facade 子包,构建就会变红。随着每个领域被改造,受约束的集合会自动增长——不需要维护名单。
与 mechanical-guardrails 并列:check-domain-acyclic(领域间无环)、check-domain-layering(e17e8388f,2026-07-27 —— 每个有门面的领域内部锁死 DDD 层序)、check-boundary-thin(53733b661,2026-08-01 —— 把"薄"做成机械规则:门面只许放别名和重导出,不许有函数体或自定义类型)、check-infra-not-domain、check-routes-not-imported。
验收
对每个领域而言:(a) 一个薄的、写了 codedoc 的 facade/ 包(package <domain>),其表面 = 该领域完整的公开 API;(b) 内部实现按 entity/usecase/service/repo/db 分类为同级子包;(c) check-domain-facade-boundary 为绿——调用方只能通过 .../facade 到达该领域。验证方式:只读 facade/ 就能说清这个领域做什么,不需要读内部实现。
落地顺序(按 ordering-depended-upon-first)
按入度排序(被依赖最多的先做):access(入度=5)→ connector(入度=3)→ capabilities / marketplace → corpus → owner → conversation。security 和 stats 是干净的叶子节点。
进度:七个核心领域都在 2026-07-27 长出了自己的 facade/ —— security(2b6aa40ea,探路者)、access(b74bdfb21,第一个有真实业务逻辑的领域)、corpus(f13c3f435)、owner(9d4e2dbd6)、conversation(19f53f4e5)、marketplace(7e0e95485)、stats(9e84715b6)—— 边界 lint 对每一个都在强制执行。
connector 仍然暂缓(截至 2026-09-07 没有 internal/connector/facade/;这个领域还是平的,子包只有 contract/、consumer/、openapi/、db/)。它还留着 #135 外部化迁移(externalization-drain)没处理完的部分(obsidian.go、mailer_*.go 那一簇、protocol_{smtp,caldav}.go 和 openapi_adapter.go 这些协议适配器)。决定(owner,2026-07-27):先跑完迁移,再对剩下的部分做 facade 拆分——对即将离开内核的代码做重排是白费功夫。当初排在"下一批"的corpus → owner → conversation(外加 marketplace、stats)就是上面已列为完成的那几个;connector 等迁移完成后再回来处理。