團隊裡有人剛剛在 src/domain/invoice.ts 裡引用了 pg。PR 可以編譯過。測試全綠。Code review 有三百行,沒人發現。
三個月後,你想把 domain logic 抽成 shared package。你抽不出來。它依賴 Postgres 的型別、connection pooling 邏輯,還有一個只在 monolith 裡存在的 custom driver wrapper。白板上那個同心圓的圖,現在變成笑話。
這就是 clean architecture 在沒有自動化強制時的標準腐化路徑。Architect 畫的箭頭都往內指。Developer 寫的 import 都往外指。Compiler 才不管你的 layer 長怎樣。
「domain 不引用 infrastructure」到底是什麼意思
在 layered architecture 裡,dependency 向內流。domain layer 位於核心,定義 entity、business rule 和 use case。它對 HTTP、SQL、queue、file system 一無所知。
Infrastructure 位於外圈。它實作 domain 定義的介面。repository interface 在 domain 裡。Postgres implementation 在 infrastructure 裡。domain 呼叫 save(invoice)。它永遠不會呼叫 pool.query()。
當 domain code 直接引用 infrastructure,會發生兩件事。第一,domain 變成沒有 database 就跑不了測試。第二,你永遠沒辦法把 Postgres 換成 DynamoDB 而不重寫 business logic。layering 的全部意義——可測試性與可替換性——就此蒸發。
為什麼 code review 不夠
每個團隊都有 senior engineer 會在 review 時抓出錯誤的 import。但那位工程師也會放假、生病,或在週五下午五點 review 一個五十個檔案的 PR。
人類是記憶體有限的 pattern matcher。自動化的 dependency rule 是無限耐心的 deterministic validator。強制 architectural boundary 的正確位置,跟強制 syntax error 的地方是一樣的:build pipeline。如果它編譯得過但違反了 dependency graph,就應該掛掉。
dependency-cruiser 如何強制 layer boundary
對於 TypeScript 與 JavaScript codebase,dependency-cruiser 能把你的 architecture diagram 變成可測試的 rule set。它解析 import graph,當 forbidden edge 出現時就讓 build 失敗。
安裝:
npm install --save-dev dependency-cruiser
npx depcruise --init
init script 會產生 .dependency-cruiser.js 設定檔。你加入一條 rule,禁止 domain 碰 infrastructure:
// .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",
},
},
};
這條 rule 說:src/domain 底下的任何檔案,如果引用了 src/infrastructure 底下的任何東西,build 就會掛。第二條 rule 則會攔截透過 node_modules 進來的 pg 或 axios import。
把它接進 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 之前就已經掛了。
錯誤長什麼樣子
當開發者打破 rule,他們會看到類似這樣的輸出:
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.
錯誤訊息會告訴他們該怎麼做。把 database call 移到 infrastructure layer 的 repository。把 repository 以介面形式傳進 use case。domain code 保持純粹。
其他生態系的替代方案
不是每個人都用 TypeScript。原則到處都一樣,只有工具不同。
Java: ArchUnit 是黃金標準。你寫的是 test,不是 config 檔:
@ArchTest
static final ArchRule domain_should_not_access_infrastructure =
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAPackage("..infrastructure..");
這會當成 JUnit test 執行,所以能直接跟 Maven 和 Gradle 整合,不需要額外的 CI 管線。
C#: NetArchTest 為 .NET 提供同樣風格的 API。寫一個 unit test,在 CI 裡跑。
Go: 目前沒有成熟的等價工具。大部分團隊用簡單的 shell script 強制:
#!/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
很 crude,但有用。很多 Go 團隊也會用自訂的 go vet analyzer 或 go/ast package 來建立更完整的檢查。
Build systems: Bazel、Gradle、Nx 都原生支援 module dependency graph。如果你把 domain 和 infrastructure 定義成獨立的 module,並明確宣告 dependency,build tool 就會免費幫你強制 boundary。前提是你得先採用這套 build system。
權衡:false positive、遷移痛苦與團隊摩擦
自動化 rule 不是免費的。開啟之前要先知道代價。
False positive 發生在你有合理的 shared utility 橫跨 layer 時。一個字串 slugifier 或自訂的 error base class 可能放在 src/shared。如果 domain 和 infrastructure 都引用它,rule 沒問題。但如果你把它放在 src/infrastructure/utils,而 domain 引用了,dependency-cruiser 就會抗議。修正方法通常是搬動 shared code,而這通常本來就是正確的決定。
遷移痛苦是真實的。如果你的 codebase 已經在三十個地方違反 rule,你不可能直接開開關關而不來一場壯烈的重構。務實的做法是從 warning 開始,在一個 sprint 內修掉現有違規,然後再升級成 error。或者,對已知的 legacy 檔案使用 pathNot 例外,從第一天起只禁止新的違規。
團隊摩擦是隱藏成本。沒有嚴格 layer 經驗的開發者會反彈。他們會爭辯說一個小 import 無害、rule 太官僚、拖慢進度。這是文化訊號,不是技術訊號。同一批開發者也會在半夜十一點為了趕功能,把 express import 進 domain model。rule 的存在正是因為人在壓力下會走捷徑。
如何在現有 codebase 中實作
你不需要大規模重寫。你需要一條 boundary 和一個衡量它的方法。
-
畫出 layer。決定哪些目錄是 domain、application、infrastructure。把它寫進
ARCHITECTURE.md。 -
加入 dependency-cruiser(或你生態系的等價工具),用一條 rule:domain 不能引用 infrastructure。
-
跑一遍。數違規數量。如果數字小,在合併 config 前先修掉。如果數字大,把 legacy path 加進
pathNot例外。 -
讓它在 CI 裡掛掉。不是 optional。不是 warning。是一個會擋住 merge 的 error。
-
每次有開發者觸發錯誤時,幫他們搬 import。不要只說 build 紅了。解釋 code 應該放在哪裡、為什麼。
兩週內,團隊就會停止試圖把 pg import 進 domain 檔案。白板上的 architecture diagram 終於會跟 repository 裡的 code 對得起來。
FAQ
Application layer 應該被允許引用 infrastructure 嗎?
通常可以。application layer 負責協調 use case。它可以知道 repository 和外部服務,但應該依賴介面而非具體實作。如果你想要嚴格執行,加第二條 rule:application 不能引用 infrastructure 的實作,只能引用它們的介面。
Domain event 呢?
Domain event 是常見的漏洞。domain event 是純資料,所以屬於 domain layer。發布它的 infrastructure event bus 屬於 infrastructure。application layer 把 domain event handler 訂閱到 bus。domain 本身永遠不知道 bus 存在。
我可以搭配 Nx 或 Turborepo 使用嗎?
可以。Nx 內建 module boundary rule,能在 project 層級運作。如果你的 domain 和 infrastructure 是獨立的 Nx library,你可以不用額外工具就強制執行。Bazel 和 Gradle 也一樣。
如果我需要 infrastructure 裡的 utility 怎麼辦?
你不需要。把 utility 搬到沒有 infrastructure dependency 的 shared common 或 kernel package。如果它真的需要 infrastructure,那它不是 utility。它是 infrastructure。
結論
沒有自動化強制的 clean architecture 只是君子協定。君子協定撐不過 production deadline。
dependency-cruiser、ArchUnit,或甚至一個帶 grep 的 shell script,都能把你的 architecture 從「建議」變成「保證」。domain 保持純粹。infrastructure 保持可替換。下一次有人試圖把 pg import 進 business rule,build 會在 PR 抵達人類 reviewer 之前就掛掉。
從一條 rule 開始。讓它在 CI 裡變紅。修掉第一個違規。其他的會跟著來。