WeHelp
Clean Architecture
2026-06-18 17:05:48
# Clean Architecture 我是進入公司之後,公司剛好來了一位資深架構師,從他身上學習到了許多以前沒有接觸過的觀念。 其中對我影響最大的,就是 Clean Architecture。 關於 Clean Architecture,其實網路上已經有非常多優秀的技術文章,問 AI 也能得到完整的條列式說明。 其實我有多次嘗試寫講述 Clean Architecture,但我發現很難,僅僅透過口述 / 文章講出來,需要思考很多細節是否正確,我已經嘗試很多次,這次我想分享自己從實際開發中理解到的 Clean Architecture,我想如果有更好的視角,或是理解的又更深一層,我會再嘗試紀錄分享出來! --- # Clean Architecture 到底在解決什麼問題? 剛開始寫程式時,我們很容易把所有東西寫在一起。 例如: - Controller 直接查資料庫 - 商業邏輯混在 SQL 查詢裡 - Entity 直接回傳給前端 - 一個方法超過數百行 一開始開發速度很快。 但隨著需求增加,專案通常會開始出現一些問題: - 不敢修改既有功能 - 修改需求容易影響其他功能 - 單元測試很難撰寫 - Controller 越來越肥大 - 商業邏輯散落各處 這時候你會發現,最大的問題其實不是功能難做,而是程式越來越難維護。 而 Clean Architecture 的目的,就是希望把不同職責拆分清楚,降低各層之間的耦合。 --- # 我自己的開發習慣 每個人的開發順序都不太一樣。 有些人會從 UI 開始設計。 有些人會先思考 API。 而我自己比較習慣先推演資料庫結構,再回頭思考 UI 與 API 設計。 以前公司專案的開發方式,通常是商業邏輯與資料存取完全混在一起。 例如: ```csharp public async Task<UserResponse> GetUser(long userId) { var user = await _db.Users .FirstOrDefaultAsync(x => x.Id == userId); return new UserResponse { Id = user.Id, Name = user.Name, Password = user.Password, LoginToken = user.LoginToken }; } ``` 這種寫法其實很常見。 但問題也很明顯: - 商業邏輯直接依賴資料庫 - 回傳內容容易過度暴露 - 不容易撰寫測試 - 未來需求變動時容易受到影響 --- # 用 Clean Architecture 的思維重新設計 當開始使用 Clean Architecture 之後,我的思考方式也慢慢改變了。 以前是: ```text 想到需求 ↓ 開始寫程式 ↓ 邊寫邊改 ``` 現在則比較像: ```text 想到需求 ↓ 定義商業邏輯需要什麼能力 ↓ 定義 Repository 介面 ↓ 實作資料存取 ↓ 串接功能 ``` 例如: ```csharp public interface IUserRepository { Task<User> GetById(long userId); } ``` ```csharp public class UserLogic : IUserLogic { private readonly IUserRepository _userRepository; public UserLogic(IUserRepository userRepository) { _userRepository = userRepository; } public async Task<UserResponse> GetUser(long userId) { var user = await _userRepository.GetById(userId); return new UserResponse { Id = user.Id, Name = user.Name }; } } ``` 這樣的設計下: - Repository 專心處理資料存取 - Logic 專心處理商業規則 - Response 決定哪些資料可以對外暴露 職責會變得非常明確。 --- # Dependency Injection 的角色 當專案開始拆分層級之後,就會大量使用 Dependency Injection(DI)。 例如: ```text Controller ↓ IUserLogic ↓ UserLogic ↓ IUserRepository ↓ UserRepository ``` 很多人第一次接觸時,會覺得: > 為什麼要多寫這麼多介面? 我以前也有同樣的疑問。 但實際維護大型專案之後就會發現: 介面最大的價值,不是在現在。 而是在未來需求改變的時候。 例如: 今天 UserRepository 使用 SQL Server。 未來如果改成: - MongoDB - Redis - 第三方 API 理論上只需要更換實作。 上層的商業邏輯不需要修改。 這也是依賴反轉(Dependency Inversion)的核心概念之一。 --- # 我對 Repository 介面的理解 以前我一直認為: ```text IUserRepository 應該屬於 Repository ``` 但後來慢慢理解到另一種思考方式。 其實: ```text IUserRepository 是商業邏輯需要的能力定義 ``` 真正擁有這個介面的,應該是 Logic。 而 Repository 只是負責實作它。 也就是說: ```text UserLogic 定義需求 UserRepository 負責實現需求 ``` 這樣商業邏輯就不會依賴特定資料庫技術。 --- # 使用 Clean Architecture 之後的感受 剛開始接觸時,我覺得它很麻煩。 因為: - 檔案變多 - 介面變多 - 開發時間變長 尤其在小專案裡,甚至會覺得有點過度設計。 但當系統規模開始變大後,就會慢慢感受到它的價值。 現在開發一個功能時,我通常會先思考: - 商業邏輯有哪些能力? - Repository 應該提供哪些方法? - 哪些資料應該對外暴露? - 哪些資料不應該被存取? 很多問題其實在設計階段就已經被發現。 等真正開始實作時,反而會順利很多。 --- # 為什麼說「寫測試變簡單了」 對我來說,Clean Architecture 最有感的好處之一,就是測試變得容易很多。 回頭看一開始那種混在一起的寫法: ```csharp public async Task<UserResponse> GetUser(long userId) { var user = await _db.Users .FirstOrDefaultAsync(x => x.Id == userId); return new UserResponse { Id = user.Id, Name = user.Name }; } ``` 如果想對這段邏輯寫單元測試,會發現很麻煩: - 它直接依賴 `_db` - 要測試就得準備一個真實或假的資料庫 - 還要塞測試資料進去 - 測試跑起來又慢又不穩定 換句話說,你想測的明明是「商業邏輯」,卻被「資料庫」綁在一起,根本拆不開。 而當我們把資料存取抽成 `IUserRepository` 介面之後,情況就完全不一樣了。 因為 `UserLogic` 依賴的是介面,不是真正的資料庫,所以測試時只要替換一個假的實作就好。 ## 用 Mock 撰寫測試 以常見的 Moq 為例: ```csharp [Fact] public async Task GetUser_應該只回傳可對外的欄位() { // Arrange:準備一個假的 Repository var mockRepository = new Mock<IUserRepository>(); mockRepository .Setup(x => x.GetById(1)) .ReturnsAsync(new User { Id = 1, Name = "Oyster", Password = "should-not-expose", LoginToken = "secret-token" }); var logic = new UserLogic(mockRepository.Object); // Act var result = await logic.GetUser(1); // Assert Assert.Equal(1, result.Id); Assert.Equal("Oyster", result.Name); } ``` 這個測試完全不需要: - 連線資料庫 - 準備測試資料表 - 等待 I/O 它只專注在一件事:**給定某筆 User 資料,商業邏輯的輸出對不對。** 跑起來毫秒等級,而且每次結果都一樣。 ## 不用 Mock 套件也可以 如果不想引入 Mock 套件,自己手寫一個假的實作也很簡單: ```csharp public class FakeUserRepository : IUserRepository { private readonly User _user; public FakeUserRepository(User user) { _user = user; } public Task<User> GetById(long userId) { return Task.FromResult(_user); } } ``` ```csharp [Fact] public async Task GetUser_不應該回傳密碼與Token() { var repository = new FakeUserRepository(new User { Id = 1, Name = "Oyster", Password = "should-not-expose", LoginToken = "secret-token" }); var logic = new UserLogic(repository); var result = await logic.GetUser(1); // UserResponse 根本沒有 Password / LoginToken 欄位 // 從型別層面就保證了不會過度暴露 Assert.Equal("Oyster", result.Name); } ``` 這也呼應了前面提到的 Excessive Data Exposure 問題。 當回傳型別被明確設計成 `UserResponse`,加上測試替我們守住這條界線,敏感欄位就很難不小心被外洩出去。 ## 真正的關鍵在「依賴介面」 其實測試會變簡單,並不是因為用了某個測試框架。 而是因為架構讓商業邏輯**依賴介面、而不是依賴具體實作**。 只要能輕鬆替換掉外部依賴,測試自然就容易寫。 這也是為什麼當初覺得「多寫那麼多介面很麻煩」,後來卻會慢慢覺得值得的原因之一。 --- # 結語 Clean Architecture 並不是為了讓專案看起來比較厲害。 它真正的目的,是讓商業邏輯不要被資料庫、框架或 UI 綁死。 當需求持續增加時,你仍然可以: - 容易修改 - 容易測試 - 容易擴充 - 容易維護 這也是我實際使用之後,感受最深的一件事情。 或許一開始會覺得麻煩。 但當專案維護一年、兩年之後,你會很慶幸當初有把架構設計好。 使用 AI 實踐 Clean Architecture 更是一件輕鬆的事情,要注意的是,有時候我們是很難用 AI 寫出自己不知道的程式碼,或是寫出來會感到困惑,在 AI 時代下,能夠將 AI 控制的好,就是軟體工程師的專業,而能寫出符合專案設計的程式碼,也是專業技能之一 意思是說,不一定要使用特定框架、架構,單看想完成什麼事情而決定 工程師就是能避免—過度設計、設計不良,將事情做的漂亮,也是專業能力