SOLID 設計審查
引導代理以具體的 .NET 視角,逐一檢視 C# 類別或模組是否符合每個 SOLID 原則,指出程式碼異味、違反的原則以及最小的重構方案 — 優先處理會導致實際耦合或頻繁修改的違規,而非純粹理論上的吹毛求疵。
使用時機
- 類別變得難以管理 — 一個檔案中同時包含了 IO、商業邏輯、對映和副作用。
- 一個 PR 引入了新的型別變體,必須編輯現有的
switch或if-else鏈才能容納它。 - 建構函式直接
new出自己的依賴項,或直接參考DateTime.Now/static輔助方法。 - 介面為了滿足多個不相關的呼叫者而累積了許多成員,導致必須實作空操作的方法。
流程
- 識別待審查的類別/模組。 說明其陳述的職責。如果描述其職責時需要用到「和」或「或」,這本身就是一個訊號。
- 依序檢視每個 SOLID 原則,使用下面的 C# 透鏡。將每個原則視為單獨的檢查,不要只找到一個問題就停止。
- 指出程式碼異味和違反的原則。 「自己組裝
HttpClient和SqlConnection的肥大建構函式」→ DIP;「基底覆寫拋出NotSupportedException」→ LSP。要精確。 - 提出最小的重構方案,並指明 C# 機制。 指出型別、模式或 DI 註冊的變更 — 而不僅僅是「應用該原則」。泛泛的「遵循 SOLID」建議不算發現。
- 根據實際影響進行優先排序。 一個同時處理 EF 持久化和電子郵件發送的上帝服務,每次衝刺都會造成變動;一個有 12 個成員、但沒有呼叫者完全使用它們的
IOrderService,是真正的 ISP 違規。一個恰好滿足兩個鬆散相關用例的sealed類別,如果它只有一個變更原因,則不算是違反 SRP。區分那些會造成傷害的違規和那些僅僅是不純粹的情況。
.NET / Azure 檢查
SRP — 單一職責原則
- 上帝服務 / 上帝類別。 一個同時觸及 EF Core(
_context.SaveChangesAsync)、傳送電子郵件(_emailSender.SendAsync)、驗證商業規則和對映到 DTO 的類別,有多個變更原因。應該提取出IOrderValidator、IOrderRepository(或直接在專用的持久化類別中使用 DbContext)和IOrderNotifier作為獨立的合作對象。 - 肥大控制器。 一個包含
if/驗證邏輯、直接呼叫資料庫並格式化回應內容的 ASP.NET Core 控制器,做了三件事。控制器應該負責協調:呼叫服務、對映結果、回傳IActionResult。商業規則屬於應用程式/領域層。 - 型別名稱或職責描述中包含「和」。
OrderValidationAndPersistenceService是一個命名異味,表示違反 SRP。如果你只能用「和」來描述這個類別,就應該拆分它。 - 多個變更原因。 問:「如果我們更換電子郵件供應商,這個類別需要變更嗎?如果我們改變資料庫結構?如果商業規則改變了?」超過一個「是」就表示超過一個職責。
OCP — 開放/封閉原則
- 基於型別鑑別的
switch/if-else增長。 服務內部針對每個新的OrderType列舉值都必須重新開啟的switch(order.Type)區塊,違反了 OCP。應替換為策略模式:定義IOrderHandler(或IOrderProcessor)並為每種型別註冊一個實作;透過注入的IEnumerable<IOrderHandler>在協調器中解析,並根據handler.Handles(order.Type)進行分派。 - 硬編碼的行為擴展。 如果新增一個折扣規則、通知管道或匯出格式需要編輯現有的類別,而不是直接放入一個新的
IDiscountRule或IExporter,則該類別未對修改封閉。應在第二個案例出現之前引入抽象。 - 型別鑑別反模式。 處理類別內部的
if (order is PremiumOrder)或if (typeof(T) == typeof(FooOrder))是與上述switch等價的程式碼異味。多型或策略註冊表可以消除這些問題。
LSP — 里斯科夫替代原則
- 覆寫拋出
throw new NotSupportedException()/NotImplementedException()。 一個無法履行基底合約的子型別(例如,ReadOnlyCollection<T>在Add時拋出例外)違反了 LSP。如果無法有意義地實作該覆寫,則表示繼承體系有問題 — 應優先考慮組合或分離的介面。 - 子型別弱化後置條件。 如果基底類別保證
GetOrders()永遠不回傳null,那麼回傳null的覆寫就違反了呼叫者依賴的合約。應記錄不變式(XML 文件或契約)並在覆寫中強制執行。 - 子型別強化前置條件。 一個覆寫新增了基底中沒有的 null 檢查,或者拒絕了基底接受的合法參數範圍,這會破壞傳遞有效基底合約輸入的程式碼。子型別必須接受基底接受的所有輸入。
- 呼叫端程式碼中普遍存在
is/as下行轉型。 呼叫者中的if (handler is SpecialHandler sh) sh.SpecialMethod()表示抽象已破裂 — 該方法應屬於基底介面,或者這些型別不應共用同一個繼承體系。頻繁的下行轉型是 LSP / 抽象失敗的訊號。
ISP — 介面隔離原則
- 肥大介面(10 個以上成員)且包含不相關的關注點。 一個公開了
CreateOrder、CancelOrder、GetInvoice、SendConfirmation、ExportToCsv、CalculateTax、ValidateAddress和GetShippingRates的IOrderService,會強迫每個實作者和測試替身去實作它們不用的方法。應拆分為角色介面:IOrderWriter、IOrderReader、IOrderNotifier、IOrderExporter。 - 空操作實作 /
NotImplementedException樁。 如果一個模擬或測試實作為一半的介面成員回傳throw new NotImplementedException(),則這些成員屬於不同的角色介面。空操作的存在是 ISP 違規的直接證據。 - 用戶匯入他們從不呼叫的方法。 如果
PaymentService依賴IOrderService但只呼叫GetOrder,則它應該依賴IOrderReader。縮小依賴範圍使耦合更明確,並使測試更便宜。
DIP — 依賴反轉原則
- 在類別內部
new出具體依賴項。 在服務內部建立的new HttpClient()會產生隱藏的依賴,繞過 DI 容器,洩漏 Socket(無連線池),並使類別無法在不使用反射技巧的情況下進行測試。應注入IHttpClientFactory(具型別用戶端)或透過建構函式注入具名用戶端。同樣的模式也適用於new SqlConnection(connectionString)— 應注入IDbConnectionFactory,或讓 EF Core / Dapper 透過註冊的DbContext管理連線。 - 依賴具體實作而非抽象。 建構函式型別為
SqlOrderRepository而非IOrderRepository,會將類別與儲存技術耦合。任何應該可重用或可隔離測試的類別,都必須僅依賴介面或抽象型別。 - 對
DateTime.Now/ 靜態狀態的隱藏耦合。 商業方法內部的var now = DateTime.Now會使類別不確定且無法在不使用時間旅行技巧的情況下進行測試。應引入ISystemClock(或使用 .NET 8+ 中的TimeProvider)並注入它。同樣的原則適用於Environment.MachineName、Random.Shared或任何其他環境靜態變數。 - 可變的靜態單例。 服務類別中的
static Dictionary<string, Order> _cache是繞過 DI 生命週期管理且非執行緒安全的隱藏共享狀態。應替換為透過 DI 注入的IMemoryCache/IDistributedCache,這支援適當的範圍和逐出策略。
判斷備註: 並非每個
new都是 DIP 違規。new OrderDto(order)(一個純資料包)或new List<T>()(一個標準集合)是沒問題的。DIP 關注的是當被建構的物件具有行為、外部 I/O 或配置時 — 也就是說,當將其替換為測試替身或替代實作會很有價值時。
紅旗訊號
| 訊號 | 重要性 |
|---|---|
在服務方法或建構函式內部建構 new HttpClient() | 繞過 IHttpClientFactory 連線池(負載下會導致 Socket 耗盡),隱藏了依賴項,並使類別無法在沒有生產 I/O 的情況下進行測試。 |
協調器類別中的 switch(order.Type) 或 if (x is ConcreteType) | 每當有新的變體加入時就必須重新開啟;呼叫者對型別階層了解過多。應替換為從 DI 解析的 IEnumerable<IHandler>。 |
在非抽象的覆寫中拋出 throw new NotImplementedException() 或 throw new NotSupportedException() | 子型別無法履行基底合約 — LSP 違規。應正確實作該方法或重組階層。 |
| 一個有 10 個以上成員的介面,但呼叫者只使用其中 2-3 個 | 強迫產生大型測試替身;表示多個角色介面已被合併為一個。 |
一個透過 new AppDbContext(options) 自行建構 DbContext 的類別 | 繞過 DI 生命週期管理(Scoped vs Transient vs Singleton),阻止連線池,並破壞可測試性。應注入 AppDbContext 或 IDbContextFactory<AppDbContext>。 |
在商業方法內部直接呼叫 DateTime.Now | 在測試中不確定;與系統時鐘的耦合隱藏了一個邏輯依賴項。應注入 TimeProvider(.NET 8+)或 ISystemClock。 |
控制器動作包含 if/switch 商業邏輯或 EF 查詢 | 控制器承擔了屬於應用程式/領域層的職責;對商業規則的單一變更需要觸及控制器。 |
static 可變欄位用作程序內快取 | 沒有明確鎖定則非執行緒安全;繞過 DI 範圍;無法被逐出或觀察。應使用 IMemoryCache。 |