cover

結論先講

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,先分離介面就夠

相關文章