重構 04:約束機制
在 〈重構三〉 中,我移除了 Core 這個目錄,改用更有語意的名稱來區分不同層級的模組:
- Apps/
- Domains/
- Foundation/
除了讓名稱更有意義之外,把模組拆成不同層級、並明確劃出彼此的邊界之後,專案才不會在接下來、甚至更長遠的開發裡再度失控。清楚的分層同時也讓 Claude Code 更容易掌握專案結構 — 它不需要翻遍整個 codebase 才能判斷一個類別、檔案該放在哪裡,能省下不少無謂的探索成本。
在這次重構裡的開發準則有三條:
- 不建立沒有語意的目錄,例如:Core、Shared、Utils
- 相依方向只能往下:Apps → Domains → Foundation
- 用領域知識判斷類別該落在哪一層,例如:tenant 跟 organization 的關係是什麼?
這三個準則的性質並不相同,有些可以轉化成明確的檢查條件,有些則必須靠領域知識來判斷。能被明確定義的部分,最好的做法就是用機制強制執行 — 例如 Claude Code 的 Skills、Hooks,或者直接寫成測試,讓每一次開發迴圈與 CI 都能擋下違規。
時程壓力、人為疏忽,或是 Claude Code 執行時偏離時,都能在這一層被攔下來。
Skill
上述提到開發準則分為兩種:可以轉化成明確的檢查條件的,和只能靠領域知識判斷的,Skill 主要處理後者。
Skill 就是一個 markdown 檔,放在專案的 .claude/skills/<名稱>/SKILL.md。frontmatter 只有兩個必要欄位,其中 description 是觸發器,Claude Code 靠它決定要不要載入這份 skill,所以我把觸發時機直接寫進去:實作前判斷放哪裡,實作後驗證有沒有放對:
---
name: module-boundaries
description: "Use this skill BEFORE creating, moving, or renaming any
file, class, or directory under src/, before adding a new app, domain
module, or foundation concern, and AFTER such changes to verify layer
rules. Covers the placement interview, vocabulary dimensions
(app/module/tenant), bucket-word ban, dependency direction, and
arch-test verification."
---內容刻意寫成必須回答的問題,而不是一段規則條文,這樣可以避免被忽略。因為問題沒有回答的話,流程就不會繼續:
## Before implementation — placement interview
1. **Instance or rule?** An app's own code belongs to that app under
src/Apps/. A mechanism ALL apps must obey is a rule of the app
dimension — it lives in src/Foundation/Context/, never inside Apps/.
2. **Membership test.** "If every business domain were deleted, would
this concept still exist?" → a foundation module. Otherwise →
exactly one module under src/Domains/. "It is used by many modules"
is NEVER a reason to move something down a layer — shared is a
magnet, not a fence.
3. **Bucket ban.** Never create or extend Core, Shared, Common,
Support, Utils, Helpers, Misc under src/. And no loose files
directly on a layer shelf (src/Apps, src/Domains, src/Foundation).
4. **Vocabulary check.** Which dimension (app / module / tenant) does
every new name belong to?
5. **Standing boundary questions.** If the change touches one, confirm
with the user before implementing — do not guess.
- Tenant vs Organization: two modules with a dependency, or merged?
- Identity vs profile: credentials belong to Identity; employee
data does not.其中第 1、2、4 點,是用領域知識判斷該放哪一層,第 3 點則是直接聲明不准做的事情,讓 Claude Code 在推理階段嘗試這麼做時,就可以被擋下來,而不是寫了之後,才靠 CI 或是測試發現。至於相依性的方向,主要會在測試裡把關,因為那是明確的限制。
第 5 點則是我要求 Claude Code 遇到 Tenant、Organization、Identity、Profile 等關鍵詞時,必須停下來問我,而不是直接執行。因為這些跟業務領域高度相關,沒有制式的標準答案。與其讓 Cluade Code 用判斷的方式去執行,不如直接停下來問我,讓我來決定該怎麼做。
Skill 的觸發終究是機率問題,所以我在 CLAUDE.md 裡再登記一次,讓它不用只靠運氣比對 description:
## Local Skills
- `module-boundaries`: MUST use before creating, moving, or renaming
anything under `src/`, and again after the change to verify layer rules.Hook
Skill 處理的是需要判斷的狀況,而能夠寫成明確條件的狀況,就可以交給 hook。它和測試的差別在於攔截的時機:測試是在事情做完之後告訴你錯了,hook 則是擋在動作發生的那一刻,錯誤不會影響到檔案系統上。
不建立沒有語意的目錄 — 剛好是最適合這種攔截的一條準則。它不需要領域知識,只需要比對名稱。
Hook 定義在 .claude/settings.json, 會在 Claude Code 工作階段期間的特定時間點觸發。我選 PreToolUse 是因為它是唯一能在寫入發生前觸發的時間點,matcher 則限定在會動到檔案的兩個工具:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bucket-dirs.sh"
}
]
}
]
}
}Script 本身不長:
#!/usr/bin/env bash
# PreToolUse(Write|Edit): deny writes into bucket-named module dirs.
set -uo pipefail
# jq 不存在就放行
command -v jq >/dev/null 2>&1 || exit 0
# 只從 JSON 取 file_path 判斷,不讀整份 stdin
# 否則註解提到 src/Core 也會被擋
file_path=$(jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0
[ -n "$file_path" ] || exit 0
if printf '%s\n' "$file_path" | grep -Eq '/src/(Core|Shared|Common|Support|Utils|Helpers|Misc)/'; then
{
echo "BLOCKED: bucket-named directory under src/ — $file_path"
echo "Bucket words have no membership criterion, so they accrete"
echo "into a second monolith. Place the file by concept instead."
echo "Run the placement interview in"
echo ".claude/skills/module-boundaries/SKILL.md, then retry."
} >&2
exit 2
fi
exit 0- 只讀 file_path,不是整份 stdin。stdin 裡包含完整的工具參數,換句話說,檔案內容也在裡面。如果直接比對整個 JSON,很可能就會導致誤判。例如:我可能基於說明,在註解寫了
src/Core,但其實不是真的要建立目錄,但這也會導致觸發被擋下來。
訊息的最後一行把 Claude Code 導回 module-boundaries skill,於是這兩個機制不再各做各的——hook 負責偵測與否決,skill 提供正確做法,被擋下來的當下就自動接上判斷流程。少了這一行,模型只會知道某個路徑不能寫,然後換一個同樣沒有語意的名字再試一次。
- exit 2 加 stderr。hook 腳本的 stderr 內容會回饋給模型,讓它知道為什麼被擋下來,所以訊息不是寫給我看的 log,而是寫給 Claude Code 看的指令。最後一行把 Claude Code 導回 module-boundaries skill,讓這兩個機制不再各做各的 — hook 負責偵測與否決,skill 提供正確做法,被擋下來的當下就自動接上判斷流程。少了這一行,Claude Code 只會知道某個路徑不能寫,然後可能又換一個同樣沒有語意的名字再試一次。
建完立刻驗證,餵假的 JSON 進去看三種路徑:
❯ echo '{"tool_input":{"file_path":".../src/Core/Http/M/SetModuleConfig.php"}}' \
| .claude/hooks/guard-bucket-dirs.sh; echo "exit=$?"
BLOCKED: bucket-named directory under src/ — .../src/Core/Http/M/SetModuleConfig.php
Bucket words (Core, Shared, Common, Support, Utils, Helpers, Misc) are banned as module names:
they have no membership criterion, so they accrete into a second monolith.
Place the file by concept instead (Foundation/Tenancy, Foundation/Context, a Domains/ module,
or an app under Apps/). Run the placement interview in
.claude/skills/module-boundaries/SKILL.md, then retry with the new path.
exit=2Arch Test
skill 管判斷、hook 管路徑,最後還剩相依方向:Apps → Domains → Foundation。這正好是 Pest arch testing 的強項,而且它可以跑在 CI,連本機 hook 如果被繞過都可以守得住:
arch('foundation does not depend on apps or domains')
->expect('Khia\Foundation')
->not->toUse(['Khia\Apps', 'Khia\Domains']);這段測試規則裡沒有任何模組名,這是分層命名(Khia\Foundation\Tenancy 而不是扁平的 Khia\Tenancy)帶來的好處。新模組產生在父 namespace 底下時,就會自動被測試規則涵蓋,不需要手動每次逐一加進去,讓規則本身的維護成本降低。
最後
最終,三道防線各司其職:
| 檢查 | 性質 | 執行者 | 時機 |
|---|---|---|---|
| 不建立沒有語意的目錄 | 明確型 | hook(harness 執行) | 每次寫檔前 |
| 相依方向只能往下 | 明確型 | arch test(CI 執行) | 每次 push |
| 用領域知識判斷類別該落在哪一層 | 判斷型 | skill(Claude Code 執行) | 實作前後 |
假設未來的某天這樣走:我或者 Claude Code 想把新類別寫進 src/Core/ → hook 直接擋下,錯誤訊息指向 skill → 讀了 skill,確認歸屬:這是某個 app 的私有物,還是所有 app 都要遵守的規則?是規則 → src/Foundation/Context/→ 檔案放對位置 → commit → arch test 在 CI 確認沒有相依逆流。
整個流程裡,不再倚賴這次重構的記憶,而是倚賴已經建立的規則與測試。