
結論先講
Clean Architecture 由 Robert C. Martin(Uncle Bob)提出。它不是一種框架,是一個原則:
依賴只能指向內(Dependency Rule):外層可以知道內層,內層絕對不能知道外層。
這個原則解決一個核心問題:你的商業邏輯會不會被技術選擇污染?
舉例:你用 MySQL 寫訂單系統。三年後要換 PostgreSQL。如果你的商業邏輯直接 SELECT * FROM orders,那你要改所有用到 DB 的商業邏輯。如果商業邏輯只依賴 OrderRepository 介面,不管底層是誰,只改一個 Adapter 就好。
這篇拆解 Clean Architecture 的四層、Dependency Rule 的實務、實作範例、以及什麼時候別用。
四層同心圓
┌──────────────────────────────────────────────────────┐
│ Framework & Drivers │
│ (Web 框架、DB、UI、外部服務) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ (Controller、Presenter、Gateway、Repository)│ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ Application Business Rules │ │ │
│ │ │ (Use Cases) │ │ │
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ Enterprise Business Rules │ │ │ │
│ │ │ │ (Entities) │ │ │ │
│ │ │ └───────────────────────────┘ │ │ │
│ │ └────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
依賴方向:外 → 內(只能這個方向)
最內圈:Entity(企業業務規則)
最穩定的部分。這層不知道資料怎麼存、API 怎麼暴露、UI 怎麼長。
// entities/Order.ts
export class Order {
constructor(
public readonly id: string,
public readonly items: OrderItem[],
public readonly customerId: string,
private status: OrderStatus,
) {}
// 業務規則:只有 pending 狀態能取消
cancel() {
if (this.status !== OrderStatus.Pending) {
throw new Error('Only pending orders can be cancelled');
}
this.status = OrderStatus.Cancelled;
}
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}注意 Entity 裡沒有任何:
- SQL
- HTTP
- 框架相關 annotation
- 資料庫欄位 mapping
純粹商業邏輯。
第二圈:Use Case(應用業務規則)
編排 Entity 做事情。
// use-cases/CancelOrder.ts
export class CancelOrderUseCase {
constructor(
private orderRepo: OrderRepository, // 介面,不是實作
private notifier: Notifier, // 介面
) {}
async execute(orderId: string) {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error('Order not found');
order.cancel(); // Entity 裡的商業規則
await this.orderRepo.save(order);
await this.notifier.notify(order.customerId, 'Order cancelled');
}
}Use Case 不知道:
- Repository 底層是 SQL 還是 NoSQL
- Notifier 是 email 還是 SMS
只依賴抽象介面。
第三圈:Interface Adapter
把外部世界翻譯成內部語言。
// adapters/SqlOrderRepository.ts
export class SqlOrderRepository implements OrderRepository {
constructor(private db: Database) {}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query('SELECT * FROM orders WHERE id = ?', [id]);
if (!row) return null;
// 把 DB row 翻譯成 Entity
return new Order(
row.id,
row.items,
row.customer_id,
row.status as OrderStatus,
);
}
async save(order: Order): Promise<void> {
await this.db.query(
'UPDATE orders SET status = ? WHERE id = ?',
[order.status, order.id],
);
}
}// adapters/OrderController.ts
export class OrderController {
constructor(private cancelOrderUseCase: CancelOrderUseCase) {}
async handleCancel(req: Request, res: Response) {
try {
await this.cancelOrderUseCase.execute(req.params.id);
res.status(200).json({ ok: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
}
}Adapter 是薄層。主要工作是「格式轉換」跟「框架整合」。
最外圈:Framework & Drivers
Express、MySQL driver、React — 這些都是細節。
// main.ts
const db = new MySqlDatabase(config);
const orderRepo = new SqlOrderRepository(db);
const notifier = new EmailNotifier();
const cancelOrderUseCase = new CancelOrderUseCase(orderRepo, notifier);
const orderController = new OrderController(cancelOrderUseCase);
const app = express();
app.post('/orders/:id/cancel', (req, res) => orderController.handleCancel(req, res));換 DB、換框架,只改 main.ts 跟 Adapter。Entity / Use Case 完全不動。
核心:Dependency Rule 的實踐
介面屬於誰
OrderRepository 介面放在哪層?
❌ 壞:放在 adapters/(外層)
// adapters/OrderRepository.ts ← 錯誤位置
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
}
// use-cases/CancelOrder.ts
import { OrderRepository } from '../adapters/OrderRepository'; // 內層 import 外層!違反規則✅ 對:介面放在 use-cases/ 或 entities/
// use-cases/OrderRepository.ts ← 內層定義介面
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
}
// adapters/SqlOrderRepository.ts
import { OrderRepository } from '../use-cases/OrderRepository'; // 外層 import 內層,符合規則介面歸屬於使用它的那一層,不是實作它的那一層。這是 Clean Architecture 最難的部分。
依賴反轉(Dependency Inversion)
傳統:
Use Case → SqlRepository(依賴具體實作)
反轉後:
Use Case → Repository 介面
SqlRepository → 實作 Repository 介面
兩者都依賴抽象,不互相依賴具體。這是 SOLID 的 D。
什麼時候別用
Clean Architecture 有成本:
- 檔案數量倍增:每個功能要 Entity + Use Case + Interface + Adapter
- 初學者難上手:不習慣看到 5 層抽象
- 小專案過度工程:50 行就解決的事搞出 500 行
不用的時機
- 原型 / MVP:還不知道需求對不對,先快速驗證
- 一次性腳本:跑完就丟的東西
- 純 CRUD:沒有複雜商業規則
- 團隊不熟悉:全員抗拒就沒意義
用的時機
- 商業規則複雜(訂單狀態機、計費規則)
- 長期維護(三年以上的產品)
- 會換技術棧(或至少擔心會換)
- 團隊已有共識(或 tech lead 願意推)
原則:不要因為「潮」而用。用前想清楚你要換掉什麼。
跟 Hexagonal / DDD 的關係
Hexagonal Architecture(Ports & Adapters)
Hexagonal 是 Clean Architecture 的姐妹版本。概念一樣:核心跟外部用介面(port)溝通,具體實作是 adapter。
差別:Hexagonal 強調「六邊形」的對稱性(每個 port 都對稱);Clean 強調「同心圓」的層次。實務上是同一種思想的不同表達。
DDD(Domain-Driven Design)
DDD 專注如何設計 Entity 跟 Use Case。Clean Architecture 專注怎麼擺放這些東西。兩者互補:
DDD 告訴你:Entity 該有什麼行為、Aggregate 怎麼分邊界
Clean 告訴你:把這些放哪、誰依賴誰
很多大型專案同時用 DDD + Clean。
簡化版:Ports & Adapters 最小實踐
不用全套 Clean。只把「介面」跟「實作」分開就已經賺到 80%:
// domain/UserRepository.ts(介面)
export interface UserRepository {
findById(id: string): Promise<User | null>;
}
// infra/PostgresUserRepository.ts(實作)
export class PostgresUserRepository implements UserRepository { ... }
// test/InMemoryUserRepository.ts(測試用實作)
export class InMemoryUserRepository implements UserRepository { ... }這樣就:
- 測試時可以用 InMemory 版本
- 換 DB 只要換 Adapter
- 商業邏輯不碰 SQL
「Clean Architecture 精神 20%,收穫 80%」。
實戰 Checklist
- 商業邏輯不直接寫 SQL 或 HTTP
- 外層依賴內層,不反過來
- 介面屬於使用者那層,不是實作者那層
- Entity 可以單獨 unit test(不用 mock DB)
- 換 DB 只改 Adapter,不動 Use Case
- 每個依賴都 inject,不要 new 具體類別
- 小專案 / 原型不用強套完整 Clean,先分離介面就夠
相關文章
- Architecture Patterns Roadmap
- MVC(入門層架構)
- API 設計
- Trade-off 框架 — 什麼時候該用什麼架構
