2026-09-23·by Sijie Wang#standmeet#architecture#design

domain-facade-and-ddd-layout

每个领域:内部 DDD 分层 + 一层薄的对外 facade

上级:backend-domain-modules

问题(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-domaincheck-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。securitystats 是干净的叶子节点。

进度:七个核心领域都在 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.gomailer_*.go 那一簇、protocol_{smtp,caldav}.goopenapi_adapter.go 这些协议适配器)。决定(owner,2026-07-27):先跑完迁移,再对剩下的部分做 facade 拆分——对即将离开内核的代码做重排是白费功夫。当初排在"下一批"的corpusownerconversation(外加 marketplacestats)就是上面已列为完成的那几个;connector 等迁移完成后再回来处理。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

domain-facade-and-ddd-layout