团队里有人刚在 src/domain/invoice.ts 里引入了 pg。PR 编译通过。测试全绿。代码审查长达三百行,却没人发现。
三个月后,你想把领域逻辑抽成一个共享包。做不到。它依赖了 Postgres 的类型、连接池逻辑,以及一个只在单体应用里存在的自定义驱动封装。白板上那套同心圆架构图,此刻成了笑话。
这就是没有自动化 enforcement 时,整洁架构的标准腐化路径。架构师画的是指向圆心的箭头。开发者写的却是朝外的 import。编译器才不在乎你的分层。
“领域不能引用基础设施”到底是什么意思
在分层架构中,依赖向内流动。位于中心的领域层定义实体、业务规则和使用场景。它对 HTTP、SQL、队列、文件系统一无所知。
基础设施在外圈。它实现领域层定义的接口。仓库接口放在领域层。Postgres 实现放在基础设施层。领域调用 save(invoice)。它绝不调用 pool.query()。
一旦领域代码直接引入基础设施,两件事就会发生。第一,不跑数据库就没法测试领域。第二,想从 Postgres 换到 DynamoDB,必须重写业务逻辑。分层的全部意义——可测试性和可替换性——瞬间蒸发。
为什么代码审查不够
每个团队都有一位资深工程师,能在审查时揪出错误的 import。但这位工程师也会度假、生病,或者在周五下午五点审一个五十个文件的 PR。
人类是内存有限的模式匹配器。自动化依赖规则是拥有无限耐心的确定性校验器。强制执行架构边界的正确位置,和强制执行语法错误的地方应该是同一个:构建流水线。如果代码编译通过但违反了依赖图,它就应该失败。
dependency-cruiser 如何 enforce 层边界
对于 TypeScript 和 JavaScript 代码库,dependency-cruiser 能把你的架构图变成一套可测试的规则。它会解析 import 图,并在出现禁止的依赖边时让构建失败。
安装:
npm install --save-dev dependency-cruiser
npx depcruise --init
初始化脚本会生成 .dependency-cruiser.js 配置文件。你只需加一条规则,禁止领域层触碰基础设施:
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: "domain-cannot-import-infrastructure",
comment:
"Domain code must not depend on infrastructure. Move the import to an adapter or repository.",
severity: "error",
from: {
path: "^src/domain",
},
to: {
path: "^src/infrastructure",
},
},
{
name: "domain-cannot-import-node-modules",
comment:
"Domain code must not depend on external I/O libraries.",
severity: "error",
from: {
path: "^src/domain",
},
to: {
dependencyTypes: ["npm"],
// Allow only pure logic libraries like date-fns or lodash
pathNot: "^(date-fns|lodash|ramda)",
},
},
],
options: {
doNotFollow: {
path: "node_modules",
},
tsPreCompilationDeps: true,
tsConfig: {
fileName: "tsconfig.json",
},
},
};
这条规则的意思是:src/domain 下的任何文件如果引入了 src/infrastructure 下的任何东西,构建就会失败。第二条规则则能捕获通过 node_modules 引入的 pg 或 axios。
把它接入 CI:
# .github/workflows/ci.yml
jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx depcruise src
现在,src/domain/invoice.ts 里的 pg import 会在人类打开 PR 之前就让构建失败。
失败长什么样
当开发者打破规则时,他们会看到如下输出:
error domain-cannot-import-infrastructure: src/domain/invoice.ts → src/infrastructure/database/pool.ts
Domain code must not depend on infrastructure. Move the import to an adapter or repository.
error domain-cannot-import-node-modules: src/domain/invoice.ts → pg
Domain code must not depend on external I/O libraries.
✖ 2 dependency violations (2 errors, 0 warnings). Showing only the first 2.
错误信息已经告诉了他们该怎么做。把数据库调用移到基础设施层的仓库里。将仓库以接口形式注入使用场景。领域代码保持纯净。
其他生态的替代方案
不是所有人都用 TypeScript。原则到处都一样,只有工具不同。
Java: ArchUnit 是黄金标准。你写的是测试,不是配置文件:
@ArchTest
static final ArchRule domain_should_not_access_infrastructure =
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAPackage("..infrastructure..");
这作为 JUnit 测试运行,因此无需额外 CI 配置就能集成到 Maven 和 Gradle 中。
C#: NetArchTest 为 .NET 提供了同样的 API 风格。写个单元测试,在 CI 里跑。
Go: 没有成熟的等价物。大多数团队用一个简单的 shell 脚本 enforce:
#!/bin/bash
# scripts/check-domain-imports.sh
if grep -r "infrastructure/" internal/domain/ ; then
echo "Domain code imports infrastructure. Fix the dependency direction."
exit 1
fi
粗暴,但有效。很多 Go 团队也用自定义的 go vet analyzer 或 go/ast 包来构建更完善的检查。
构建系统: Bazel、Gradle 和 Nx 原生支持模块依赖图。如果你把领域和基础设施定义为独立模块,并显式声明依赖,构建工具就会免费帮你 enforce 边界。前提是你得先接受这套构建系统。
权衡:误报、迁移阵痛和团队摩擦
自动化规则不是免费的。打开之前,你得先了解代价。
误报会出现在那些位于层之间的合法共享工具上。一个字符串 slugifier 或自定义的错误基类可能放在 src/shared。如果领域和基础设施都引入它,规则没问题。但如果你把它放在 src/infrastructure/utils,领域又引入了它,dependency-cruiser 就会报错。修复方法通常是移动共享代码,而这往往本身就是正确的做法。
迁移阵痛是真实的。如果你的代码库已经有三十处违规,你不能直接按下开关,除非你想来一次英雄式重构。务实的做法是先设为 warning,在一个 sprint 内修复现有违规,再提升为 error。或者,用 pathNot 给已知遗留文件开例外,从第一天起禁止新违规。
团队摩擦是隐性成本。从未接触过严格分层 enforcement 的开发者会抵触。他们会争辩说一个小小的 import 无害,规则是官僚主义,拖慢进度。这是文化信号,不是技术信号。同样是这些开发者,也会在晚上 11 点为了发版把 express 引入领域模型。规则的存在,正是因为压力下的人类会走捷径。
如何在现有代码库中落地
你不需要大重写。你需要一个边界,以及衡量它的方式。
-
画出分层。确定哪些目录是 domain、application 和 infrastructure。把它写进
ARCHITECTURE.md。 -
引入 dependency-cruiser(或你所在生态的等价工具),只加一条规则:domain 不能引入 infrastructure。
-
跑一遍。统计违规数。如果数量少,在合并配置前修复。如果数量大,把遗留路径加到
pathNot例外里。 -
让它在 CI 里失败。不是可选的。不是 warning。是一个会阻断合并的 error。
-
每次有开发者触发错误时,帮他们移动 import。不要只告诉他们构建红了。解释代码应该放在哪里,以及为什么。
两周之内,团队就会停止尝试把 pg 引入领域文件。白板上的架构图,终于会和仓库里的代码对上了。
FAQ
Application 层应该被允许引入 infrastructure 吗?
通常可以。Application 层编排使用场景。它可以知道仓库和外部服务,但应该依赖接口,而非具体实现。如果你想严格 enforcement,再加一条规则:application 不能引入 infrastructure 的实现,只能引入它们的接口。
Domain events 怎么办?
Domain events 是常见的漏洞。Domain event 是纯数据,所以属于领域层。发布它的 infrastructure event bus 属于基础设施层。Application 层把 domain event handler 注册到 bus 上。领域本身永远不知道 bus 的存在。
能和 Nx 或 Turborepo 一起用吗?
可以。Nx 内置了项目级别的模块边界规则。如果你的 domain 和 infrastructure 是独立的 Nx library,你无需额外工具就能 enforce。Bazel 和 Gradle 也一样。
如果我想用 infrastructure 里的某个工具怎么办?
你不应该。把工具移到没有基础设施依赖的共享 common 或 kernel 包里。如果它真的需要基础设施,那它就不是工具。它就是基础设施。
结论
没有自动化 enforcement 的整洁架构,是君子协定。君子协定在生产截止日期面前活不下来。
Dependency-cruiser、ArchUnit,甚至一个带 grep 的 shell 脚本,都能把你的架构从”建议”变成”保障”。领域保持纯净。基础设施保持可插拔。下一次有人试图把 pg 引入业务规则时,构建会在 PR 到达人类审查者之前就让失败。
从一条规则开始。让它在 CI 里变红。修复第一个违规。其余的会随之而来。