重構三:不要叫它 Core
〈重構二〉把 modules 搬進了獨立的 package。搬家的時候,一個通用的目錄跟著搬了進來:Core/。
它在〈重構一〉就存在了,當時收留了 User 和 Organization。這次重構,我也把新寫的 middleware 放了進去 — src/Core/Http/Middleware/,理由一樣自然:這是共用的東西。
但是,Core 到底是什麼?
Core 是怎麼變成單體的
模組化單體最常見的失敗,就是 Core 長成第二個單體。機制永遠相同,收納的標準是:共用。
任何東西只要被用了兩次,就有資格搬進 Core — helper、trait、共用的 service、誰都要引用的 Enum、跨模組的 middleware。每一次搬動在當下都合理,因為「共用」這個標準永遠成立:程式碼寫得越多,被共用的東西就越多。多年後回頭看,Core 比任何業務模組都大,所有模組都相依於它,它也相依於所有模組 — 單體被拆成了十個模組,加一個新的單體。
「共用」這個準則只會不斷吸納新的檔案,回答不了什麼不該放進來。
是規則,不是共用
那什麼東西真正有資格放在系統的基礎層?我認為是:定義系統規則的東西 — 系統的不變數,而不是被共用的功能。
以一個 multi-tenant 的 ERP 為例:
- Tenancy 定義了所有模組資料的隔離 — 每一筆資料都隸屬於某個 tenant。這不是一項功能,是一條所有模組都必須遵守的規則。
- Identity 定義了現在是誰在操作 — 所有模組的行為都以它為前提。
- Organization 定義了 tenant 內部的組織結構(公司、門市、部門)— 其他模組引用它來定位自己的資料。
它們的共同點不是被很多模組使用,而是沒有它們,其他模組的規則就不成立。由此可以得出一個判斷的方式:
把所有業務模組都刪掉,這個概念還存在嗎? Tenant 存在、User 存在 — 它們屬於基礎層。 PurchaseOrder 不存在 — 它是業務領域。 Catalog 也不存在 — 就算它被每一個 app 共用。
「被很多地方用到」永遠不構成進入基礎層的理由 — 被共用只代表它是熱門的業務模組,不代表它是規則。
刪掉 Core 這個名字
接下來是這次重構裡最重要的決定:刪掉 Core 目錄,用概念的名字取代它。
src/
├── Apps/ # 進入點實體:Admin、Commerce、Pharmacy
├── Domains/ # 業務領域:Catalog、Inventory、Procurement…
└── Foundation/ # 系統規則
├── Context/ # app 維度的機制
├── Tenancy/ # Tenant model、finder、scoping
├── Identity/ # User、認證機制
└── Organization/ # 組織結構(公司、門市、部門)Core 的問題在於名字沒有語意:它的邊界由裝進去的東西定義,所以裝什麼都合理。而一個叫 Tenancy 的模組只裝得下 tenancy 的東西 — 名字本身就定義了邊界。Shared、Common、Support、Utils、Helpers 都是同一類名字,命運也相同:名字什麼都沒說的目錄,最後什麼都可能放進去。
那 Foundation/ 不會變成另一個 Core 嗎?關鍵在角色不同。Core 的問題是「以層為名的模組」 — 一個可以直接放類別的目錄,什麼都收。Foundation/ 只是分組用的層目錄 — 它自己不放任何檔案,底下只有 Tenancy、Identity 這些各自帶著明確邊界的模組。搭配一條紀律守住這個差異:層目錄底下不放單獨的檔案,一個不屬於任何模組的檔案,就是一個還沒想清楚的歸屬問題。
之所以值得多這一層目錄,是規模:這個系統終將有數十個業務模組 (採購、銷貨、物流、目錄、庫存、財務等等),攤平在 src/ 是一個很長的清單;分組之後,頂層永遠只有三個名字。
相依規則
「foundation 層」的目的不是那個目錄,而是一條相依規則:
相依只能往下:Apps → Domains → Foundation。基礎層永遠不相依於業務模組。
如果哪天 Tenancy 需要知道 Invoice,就是警訊出現的那一天。資料夾擋不住相依 — 真正守住這條規則的,是幾行測試。而分層命名還帶來一個實質的好處:規則不需要列舉模組:
// Pest 的 arch test
arch('foundation does not depend on apps or domains')
->expect('Khia\Foundation')
->not->toUse(['Khia\Apps', 'Khia\Domains']);
arch('domains do not depend on apps')
->expect('Khia\Domains')
->not->toUse('Khia\Apps');這兩條規則裡沒有任何模組名。
如果採用攤平模組的版本,就需要列舉 ['Khia\Tenancy', 'Khia\Identity', ...],每加一個模組都要記得回來更新清單,只要忘記,就會產生漏洞。而分組之後,新模組放在 Khia\Foundation\ 底下,就會自動被父 namespace 涵蓋。
有了這幾行,就算某天有人(包括我自己)想把業務邏輯塞進 Tenancy,CI 會直接拒絕。目錄擋不住的,測試擋得住;而好的命名結構,讓測試不需要維護。
回頭看〈重構一〉最大的盲點就在這裡:它把「集中在同一目錄」等同於「模組化」。但目錄只解決了找檔案的問題,沒有解決相依方向的問題,彼此保持獨立是被宣稱的,不是被驗證的。
實地演練
〈重構二〉裡負責切換各 app session 與 auth 設定的 middleware,原本被放在 src/Core/Http/Middleware/SetModuleConfig.php。Core 刪掉之後,它該搬去哪?用上述提到的方式來判斷:
- 刪掉所有業務模組,它還存在嗎?存在 — 它跟 Catalog、Inventory 無關,所以不屬於 domain 層。
- 刪掉所有 app,它還存在嗎?我第一次的答案是不存在 — 它存在的理由是「每個 app 有自己的 session cookie 和 auth model」這個目的 — 於是把它放進了 Apps 層:
src/Apps/Http/Middleware/。
但這個決定在 Apps/ 底下多出了一個不是 app 的 Http/,破壞了「Apps/ 底下每個子目錄都是一個 app」的一致性。問題出在哪?
假設錯了 — 我刪掉的是 App,但這個 middleware 是規則。刪掉 Admin、Commerce、Pharmacy 這些 App 後,任何 app 都必須這樣切換 context的規則還是依然成立 — 就像資料庫裡如果一個 tenant 都沒有,Tenancy 依然還是有意義。
所以,SetModuleConfig 是定義某個系統的規則,正是進入基礎層的條件。
所以它的家在基礎層,一個屬於系統規則的模組(未來的共用 app provider、app registry 也應該在這裡):
src/
├── Apps/ # 只放 app
│ ├── Admin/
│ ├── Commerce/
│ └── Pharmacy/
└── Foundation/
└── Context/
└── SetAppContext.php ← 搬到這裡檔名也變了。系統目前有3層:
| 詞 | 指什麼 | 例子 |
|---|---|---|
| App | 進入點/子系統 | Admin、Commerce、Pharmacy |
| Module(domain) | 業務領域 | Catalog、Procurement、Inventory |
| Foundation | 系統規則 | Tenancy |
這個 middleware 設定的是 app 的 context,不是 Catalog 那種 module。
所以它應該被命名為 SetAppContext,參數叫 $app,讀的 config 是 khia.{app}。三處用同一個詞,詞彙就對齊了,避免日後不必要的誤會,讓命名本身就是一種文件。
把教訓變成制度
這篇寫的所有判斷,都有一個共同的敵人:遺忘。
也許三個月後的我,在趕工的下午,還是會想把東西丟進一個「共用」的地方。教訓會被遺忘,制度不會。所以最後還是是把它們交給機器。透過測試,讓每次違反規則時,CI 都會失敗,並迫使我修正。
能判定的交給機器,需要判斷的留給人,並且把問題寫下來,不靠記性。
最後
〈重構一〉解決了檔案的位置,〈重構二〉把模組隔離成獨立的 package,而這一篇處理的是名字與相依的方向。
架構是演進的結果,而非預先設計的完美藍圖。而且只有把每一次的教訓變成下一次的規則,才不會讓目錄的形狀被趕工衝垮,讓好習慣會在時程壓力下鬆動;只有寫進 CI 的規則,才會在我最疲倦的那天依然繼續醒著。