重構(一):從單體應用到模組化
五年前,為了支援公司創業初期的營運需求,我使用 Laravel 開發了一個庫存管理系統。初期的功能範圍明確:進貨管理、庫存追蹤、訂單處理。借助 Laravel 的快速開發能力,系統很快就投入使用並獲得認可。
Laravel 的美好就在於此 — 它讓你可以快速將想法變成可運行的產品,並且在早期就可以得到相當多的回饋。
然而,隨著業務的發展,系統需求持續擴張。五年來,陸續加入了多租戶管理、藥品批號追蹤、供應商管理、第三方發票整合、進銷報表等功能,並逐步整合了藥局門市通路、電商購物車、POS 系統等多個子系統,這使得系統越來越大,也越來越複雜。
今天,我面對的是一個包含數十個業務模組、數百個類別檔案的大型應用程式,以及散落的數個子系統。
困境
目錄結構
最初,系統遵循 Laravel 的標準目錄結構組織檔案。這種按類型分類的方式在專案初期運作良好:
app/
├── Http/Controllers/
├── Models/
├── Jobs/
└── Listeners/但隨著業務模組增加,問題開始逐漸浮現。以採購模組為例,相關的程式碼分散在多個目錄:
app/Http/Controllers/PurchaseOrderController.php
app/Models/PurchaseOrder.php
app/Jobs/ProcessPurchaseOrder.php
app/Listeners/PurchaseOrderCreated.php
app/Notifications/PurchaseOrderNotification.php同一個業務模組的程式碼分散在五個(或更多)不同的目錄中。
更進一步觀察 app/Models/ 目錄:
app/Models/
├── User.php
├── Order.php
├── Invoice.php
├── PurchaseOrder.php
├── ScrapOrder.php
├── Stock.php
├── Product.php
└── ...(30+ 個 Models)這些 Model 除了都屬於同一類型之外,在業務邏輯上並無關聯。Invoice 與 ScrapOrder 被放在同一目錄,僅僅因為它們都是 Model,但實際上分屬不同的業務領域。
子系統的維護
隨著業務擴展,我們發展出三個子系統:
- 管理後台:供內部人員管理庫存與訂單
- 電商平台:提供客戶線上購物功能
- 門市 POS:處理實體門市的銷售作業
這些子系統最初是獨立的專案,透過 API 進行溝通。雖然將部分共用邏輯抽取為獨立的 Package,但在時間壓力與持續增長的業務需求下,仍經常出現重複實作的情況:
// 主系統
class InventoryService {
public function calculateStock($productId) {
// 庫存計算邏輯
}
}
// POS 系統
class PosInventoryService {
public function calculateStock($productId) {
// 相似的庫存計算邏輯
}
}
// 電商系統
class CommerceInventoryService {
public function calculateStock($productId) {
// 再次實作相似邏輯
}
}相同的業務邏輯在不同系統中重複實作,即使它們只有小部分差異,也增加了維護成本與出錯機率。
嘗試
我曾嘗試在各類型目錄下建立業務子目錄:
app/
├── Http/Controllers/
│ ├── Inventory/
│ ├── Procurement/
│ └── Order/
├── Models/
│ ├── Inventory/
│ ├── Procurement/
│ └── Order/
└── Jobs/
├── Inventory/
├── Procurement/
└── Order/這種方式確實改善了檔案的組織,但在實際開發時,仍然需要在多個類型目錄間切換才能完成一個功能的修改,例如:要修改採購功能時:
Http/Controllers/Procurement/PurchaseOrderController.php
Models/Procurement/PurchaseOrder.php
Jobs/Procurement/PurchaseOrderCreated.php
Listeners/Procurement/PurchaseOrderCreated.php
Notifications/Procurement/PurchaseOrderNotification.php我還是在多個目錄間穿梭,只是現在每個目錄都多了一層。
問題
這讓我開始思考:一定要按類型來組織程式碼嗎?
在開發特定業務功能時,我想要關注的是 業務邏輯的完整性,而非 Controller、Model、Job 等類型的區分。如果能將同一業務模組的所有相關類別集中在同一個目錄下,開發時就能更專注於該模組本身。
如果可以不需要按類型來組織程式碼,那麼一定要嚴格遵循完整的 領域驅動設計(DDD),實作 Bounded Context、Aggregate Root、Domain Events 等概念才可以嗎?
有什麼方式可以在實務需求與架構複雜度之間取得平衡的折衷方案?
業界實踐
在尋找解決方案的過程中,我發現許多開發者都遇到了類似的問題。
Jeffrey Way 在 Twitter 上分享的經驗印證了我的想法:
A really useful technique for slightly larger projects is to build and organize app features as if you were making a Composer package. Group all of your related models, controllers, listeners, commands, etc. under the same directory and namespace.
他建議將相關的 Models、Controllers、Listeners、Commands 等都組織在同一個目錄和命名空間下,就像開發 Composer Package 那樣。
此外,Mateus Guimarães 在 Modularizing the monolith: a real-world experience 中分享了他的實際經驗,而 Shopify 工程團隊也在 Deconstructing the Monolith 中討論了類似的架構演進。
這些實踐經驗讓我更確信:按業務領域組織程式碼是更合理的選擇。
調整
整體結構
基於以上的想法,我調整為以下的架構:
modules/
├── src/
│ ├── Apps/ # 應用程式層
│ │ ├── Admin/ # 管理後台
│ │ ├── Store/ # 門市系統
│ │ └── Commerce/ # 電商平台
│ ├── Core/ # 核心共用邏輯
│ │ ├── User/
│ │ └── Organization/
│ ├── Customer/ # 客戶管理
│ ├── Procurement/ # 採購管理
│ ├── Inventory/ # 庫存管理
│ ├── Finance/ # 財務管理
│ └── Infrastructure/ # 基礎設施
├── config/ # 模組配置檔
├── database/ # 資料庫遷移
├── routes/ # 路由定義
├── resources/ # 視圖與資源
└── tests/ # 測試檔案新的架構下,config、database、routes 等目錄並非放在各個子系統下,而是統一在 modules 根目錄。主要原因是這些目錄管理的是 跨模組的共用資源:
- database/migrations:所有模組的資料表變更需要統一管理版本
- routes/:雖然按子系統分檔,但統一放置便於查看整體路由結構
- config/:模組層級的配置,各子系統透過 Service Provider 存取
- tests/:測試策略統一管理,但仍按模組組織
統一管理共用資源,同時保持模組獨立,在實務上取得了很好的平衡。
模組結構
每個業務模組內部組織所有相關的類別。以採購模組為例:
modules/src/Procurement/
├── Http/
│ └── Controllers/
│ ├── PurchaseOrderController.php
│ └── ReplenishmentController.php
├── Jobs/
│ └── FilterReplenishmentCandidate.php
├── Listeners/
│ └── LogReplenishmentHistory.php
├── Models/
│ ├── PurchaseOrder.php
│ └── PurchaseOrderItem.php
├── Enums/
│ ├── ReplenishmentReason.php
│ └── ReplenishmentStatus.php
└── ReplenishmentManager.php使用時的命名空間也更加明確:
// 過去
use App\Models\PurchaseOrder;
// 現在
use Modules\Procurement\PurchaseOrder;從命名空間即可清楚看出 PurchaseOrder 屬於採購模組,且相關的所有類別都可在 Procurement 目錄下找到。 這種組織方式讓開發時能夠專注於單一業務領域,而不需要在多個類型目錄間來回切換。
子系統架構
各子系統統一放置在 modules/src/Apps/ 下:
modules/src/Apps/
├── Admin/
│ ├── Http/
│ ├── Models/
│ ├── Actions/
│ └── AdminServiceProvider.php
├── Store/
│ ├── Http/
│ ├── Models/
│ └── StoreServiceProvider.php
└── Commerce/
├── Http/
├── Models/
└── CommerceServiceProvider.php每個子系統透過自己的 Service Provider 管理路由、認證、設定等,彼此保持獨立。同時,它們可以:
- 共用核心模組的業務邏輯
- 繼承並擴展既有功能
- 在需要時覆寫特定實作
所有系統在同一個專案中維護,有效避免了程式碼重複。
Composer 設定
在 composer.json 中註冊模組的命名空間:
{
"autoload": {
"psr-4": {
"App\\": "app/",
"Modules\\": "modules/src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/",
"Modules\\Tests\\": "modules/tests/"
}
}
}效益
開發效率提升
調整前:修改採購功能需要在多個目錄間切換
app/Http/Controllers/...
app/Models/...
app/Jobs/...
app/Listeners/...調整後:所有相關檔案集中在單一目錄
modules/src/Procurement/程式碼重用
調整前:各子系統獨立維護相似邏輯
Admin/InventoryService.php
Store/InventoryService.php
Commerce/InventoryService.php調整後:共用核心模組
// modules/src/Inventory/InventoryService.php
namespace Modules\Inventory;
class InventoryService { ... }
// 各子系統直接使用
use Modules\Inventory\InventoryService;擴充性
需要新增物流系統時,只需在 modules/src/Apps/ 下建立新目錄:
modules/src/Apps/
└── Logistics/
├── Http/
├── Models/
└── LogisticsServiceProvider.php新系統可立即重用所有現有的業務模組邏輯。
最後
從標準的 Laravel 目錄結構演進到模組化架構,是一段漫長但值得的過程。因為這中間還必須兼顧公司的營運、增加新功能、調整既有功能等,而不是一件可以不受干擾的任務在進行。
重構的最佳時機往往比我們想像的晚。在系統建構過程中,我們會持續加深對業務領域的理解。在缺乏領域知識的情況下就設計複雜的系統架構或微服務,只會增加不必要的風險與成本。
David Heinemeier Hansson 在 The Majestic Monolith 中也探討了這個議題。他並非反對良好的軟體實踐,而是提醒我們:不要花費數週甚至數月的時間,去設計一個尚未充分理解的系統,尤其當它可能始終只是一個小型專案時。
架構是演進的結果,而非預先設計的完美藍圖。
適合的架構方案取決於團隊規模、應用程式複雜度,以及實際的運作情況。無論選擇哪種架構,在演變過程中都會經歷一段痛苦和掙扎的時期。但當發現開發效率下降、修改系統變得困難時,也許就是該重新思考的時候了。
這次的重構讓我親身經歷了完整的循環:體會單體架構的便利、察覺它逐漸成為瓶頸、最終思考並實施改變。這不是來自上層的決策,而是基於實際痛點的深刻體會。
重構的目標不在於追求理論上的完美架構,而是 讓程式碼更容易理解、維護與擴展。沒有絕對最好的架構,只有最適合當前情境與團隊的架構。