Wu-Hsien Yu/ 文章
文章

重構三:不要叫它 Core

重構二〉把 modules 搬進了獨立的 package。搬家的時候,一個通用的目錄跟著搬了進來:Core/

它在〈重構一〉就存在了,當時收留了 UserOrganization。這次重構,我也把新寫的 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 的東西 — 名字本身就定義了邊界SharedCommonSupportUtilsHelpers 都是同一類名字,命運也相同:名字什麼都沒說的目錄,最後什麼都可能放進去。

Foundation/ 不會變成另一個 Core 嗎?關鍵在角色不同。Core 的問題是「以為名的模組」 — 一個可以直接放類別的目錄,什麼都收。Foundation/ 只是分組用的層目錄 — 它自己不放任何檔案,底下只有 TenancyIdentity 這些各自帶著明確邊界的模組。搭配一條紀律守住這個差異:層目錄底下不放單獨的檔案,一個不屬於任何模組的檔案,就是一個還沒想清楚的歸屬問題。

之所以值得多這一層目錄,是規模:這個系統終將有數十個業務模組 (採購、銷貨、物流、目錄、庫存、財務等等),攤平在 src/ 是一個很長的清單;分組之後,頂層永遠只有三個名字。

相依規則

「foundation 層」的目的不是那個目錄,而是一條相依規則:

相依只能往下:Apps → Domains → Foundation。基礎層永遠不相依於業務模組。

如果哪天 Tenancy 需要知道 Invoice,就是警訊出現的那一天。資料夾擋不住相依 — 真正守住這條規則的,是幾行測試。而分層命名還帶來一個實質的好處:規則不需要列舉模組

php
// 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 的規則,才會在我最疲倦的那天依然繼續醒著。

相關文章

2026.08.04從 Migrations 走回 Schema Dumps:Laravel 內建的那條路2026.07.16重構二:從模組化單體到獨立 Package2026.04.20Kindie - 開發日誌 01