本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 架構進階模式

Clean Architecture / Layered Architecture


簡介

在 Node.js 與 Express 生態系中,快速開發的便利性常讓開發者直接把所有程式碼塞在 router 或 controller 裡。雖然這樣可以在短時間內完成 MVP,但隨著需求增長、功能複雜度提升,程式碼會變得難以維護、測試成本高、修改時容易產生副作用。

Clean Architecture(亦稱 層級式架構)提供了一套明確的依賴方向與責任分離原則,讓 Domain(業務核心) 與 Framework(Express、資料庫等外部技術) 完全解耦。即使日後要換成 Koa、NestJS,或是改用 PostgreSQL、MongoDB,只要保持介面不變,整個系統仍能平滑遷移。

本篇文章以 TypeScript 為語言,示範如何在 Express 專案中落實 Clean Architecture,從目錄規劃到程式碼實作,讓初學者也能快速掌握「可測試、可擴充、可維護」的開發方式。


核心概念

1. 依賴規則(Dependency Rule)

依賴只能指向內層

  • 外層(Framework、UI、Database)可以依賴 內層(Use Cases、Domain)。
  • 內層 絕不能直接引用外層的實作,必須透過介面(Interface)抽象。

這樣的規則讓 Domain 完全不受外部框架的牽制,單元測試只需要 mock 介面即可。

2. 四層結構

層級 主要職責 典型檔案
Domain 企業核心概念(Entity、Value Object) src/domain/entities/*
Use Cases 業務流程、應用服務 src/application/usecases/*
Interface Adapters 轉換外部請求/回應與內部模型(Controller、Presenter、Repository Interface) src/interfaces/*
Framework & Drivers Express、資料庫、第三方 SDK src/infrastructure/*

圖示(文字版)
Framework → Interface Adapters → Use Cases → Domain

3. 依賴注入(DI)

透過 DI 容器(如 tsyringe、inversify)在啟動時把外層實作注入到內層介面,保持 低耦合。


程式碼範例

以下範例以「使用者註冊」功能為例,展示每一層的實作方式。專案目錄假設如下:

src/
├─ domain/
│   └─ entities/
│       └─ User.ts
├─ application/
│   └─ usecases/
│       └─ RegisterUser.ts
├─ interfaces/
│   ├─ controllers/
│   │   └─ RegisterUserController.ts
│   └─ repositories/
│       └─ IUserRepository.ts
├─ infrastructure/
│   ├─ repositories/
│   │   └─ UserRepository.ts
│   └─ server.ts
└─ container.ts   // DI 設定

1. Domain – Entity

// src/domain/entities/User.ts
export class User {
  /** 使用者唯一識別碼 */
  public readonly id: string;
  /** 使用者名稱 */
  public readonly name: string;
  /** 加密後的密碼 */
  private passwordHash: string;

  constructor(id: string, name: string, passwordHash: string) {
    this.id = id;
    this.name = name;
    this.passwordHash = passwordHash;
  }

  /** 驗證密碼是否正確 */
  public async verifyPassword(password: string): Promise<boolean> {
    // 這裡僅示範,實務上請使用 bcrypt 等加密函式庫
    return password === this.passwordHash;
  }
}

說明:Entity 只負責 封裝資料與行為,不應該直接與資料庫或 HTTP 請求互動。

2. Interface Adapter – Repository Interface

// src/interfaces/repositories/IUserRepository.ts
import { User } from '@/domain/entities/User';

export interface IUserRepository {
  /** 依 ID 取得使用者 */
  findById(id: string): Promise<User | null>;
  /** 依名稱取得使用者(註冊時檢查是否重複) */
  findByName(name: string): Promise<User | null>;
  /** 新增使用者 */
  create(user: User): Promise<void>;
}

說明:介面只描述 行為合約,實作會放在 Infrastructure 層。

3. Use Case – 註冊流程

// src/application/usecases/RegisterUser.ts
import { injectable, inject } from 'tsyringe';
import { IUserRepository } from '@/interfaces/repositories/IUserRepository';
import { User } from '@/domain/entities/User';
import { v4 as uuidv4 } from 'uuid';

interface RegisterUserDTO {
  name: string;
  password: string;
}

@injectable()
export class RegisterUser {
  constructor(
    @inject('IUserRepository') private userRepo: IUserRepository
  ) {}

  /** 執行使用者註冊 */
  public async execute(dto: RegisterUserDTO): Promise<User> {
    // 1️⃣ 檢查名稱是否已被使用
    const exists = await this.userRepo.findByName(dto.name);
    if (exists) {
      throw new Error('使用者名稱已存在');
    }

    // 2️⃣ 產生唯一 ID 與密碼雜湊(此處簡化為明文)
    const id = uuidv4();
    const passwordHash = dto.password; // TODO: bcrypt 加密

    // 3️⃣ 建立 Domain Entity
    const user = new User(id, dto.name, passwordHash);

    // 4️⃣ 永續化
    await this.userRepo.create(user);

    // 5️⃣ 回傳新建立的實體(可在 Presenter 中轉成 DTO)
    return user;
  }
}

關鍵點:

  • Use Case 只關心業務邏輯,不會直接操作 Express req/res。
  • 透過 DI 注入 IUserRepository,方便在測試時 mock。

4. Infrastructure – Repository 實作(以 MongoDB 為例)

// src/infrastructure/repositories/UserRepository.ts
import { injectable } from 'tsyringe';
import { IUserRepository } from '@/interfaces/repositories/IUserRepository';
import { User } from '@/domain/entities/User';
import { Model, Document } from 'mongoose';

// Mongoose Schema(簡化版)
interface IUserDoc extends Document {
  _id: string;
  name: string;
  passwordHash: string;
}
const UserModel: Model<IUserDoc> = require('../models/UserModel');

@injectable()
export class UserRepository implements IUserRepository {
  async findById(id: string): Promise<User | null> {
    const doc = await UserModel.findById(id).exec();
    return doc ? new User(doc._id, doc.name, doc.passwordHash) : null;
  }

  async findByName(name: string): Promise<User | null> {
    const doc = await UserModel.findOne({ name }).exec();
    return doc ? new User(doc._id, doc.name, doc.passwordHash) : null;
  }

  async create(user: User): Promise<void> {
    await UserModel.create({
      _id: user.id,
      name: user.name,
      passwordHash: (user as any).passwordHash, // 直接取私有屬性(簡化示範)
    });
  }
}

說明:此層只負責資料存取,不會有任何業務判斷。

5. Interface Adapter – Controller

// src/interfaces/controllers/RegisterUserController.ts
import { Request, Response, NextFunction } from 'express';
import { container } from 'tsyringe';
import { RegisterUser } from '@/application/usecases/RegisterUser';

export async function registerUserController(
  req: Request,
  res: Response,
  next: NextFunction
) {
  try {
    const { name, password } = req.body;
    const usecase = container.resolve(RegisterUser);
    const user = await usecase.execute({ name, password });

    // Presenter:只回傳必要資訊,避免洩漏密碼雜湊
    res.status(201).json({
      id: user.id,
      name: user.name,
    });
  } catch (err) {
    next(err);
  }
}

重點:Controller 只做 輸入驗證、呼叫 Use Case、回傳結果,所有錯誤交給全域錯誤處理 Middleware。

6. Framework – Express 初始化

// src/infrastructure/server.ts
import express from 'express';
import 'reflect-metadata';
import { registerUserController } from '@/interfaces/controllers/RegisterUserController';
import { registerDependencies } from '@/container';

const app = express();
app.use(express.json());

// 設定 DI 容器
registerDependencies();

// 路由
app.post('/api/users', registerUserController);

// 錯誤處理
app.use((err: any, _req: any, res: any, _next: any) => {
  console.error(err);
  res.status(400).json({ message: err.message });
});

export default app;

7. DI Container 設定

// src/container.ts
import { container } from 'tsyringe';
import { IUserRepository } from '@/interfaces/repositories/IUserRepository';
import { UserRepository } from '@/infrastructure/repositories/UserRepository';

export function registerDependencies() {
  // 以字串 token 綁定介面與實作
  container.register<IUserRepository>('IUserRepository', {
    useClass: UserRepository,
  });
}

常見陷阱與最佳實踐

陷阱 為什麼會發生 解決方式
把框架代碼寫在 Use Case 內 直接使用 req.body、res.json() 會讓業務邏輯與 Express 緊耦合。 保持 Use Case 純粹,只接受 DTO,回傳 Domain Entity。
依賴倒置失敗:直接 new Repository 測試時難以 mock,且換資料庫需改所有呼叫處。 使用 DI 容器 或 Factory 注入介面。
Entity 內放太多基礎 CRUD 使 Entity 變成「資料表映射」而非業務模型。 只保留 行為(如驗證、計算),資料存取交給 Repository。
錯誤傳遞不一致 有的拋 Error、有的回傳 null,導致上層需要多種判斷。 統一例外類型(如自訂 DomainError、ApplicationError),在 Middleware 統一處理。
缺少單元測試 Clean Architecture 的好處在於可測試,若不寫測試會失去價值。 為每個 Use Case 撰寫 純函式測試,使用 mock Repository。

最佳實踐

  1. 保持層級純粹:每層只做自己該做的事。
  2. 使用 TypeScript 的型別系統:介面(Interface)與 DTO 能有效防止錯誤傳遞。
  3. 建立共用的錯誤類別:BusinessRuleError, InfrastructureError,讓上層能根據錯誤類型回傳正確 HTTP 狀態碼。
  4. 在專案根目錄放置 src/,以 @ 為別名:tsconfig.json 中設定 paths,提升 import 可讀性。
  5. 自動化測試:使用 jest + ts-jest,配合 tsyringe 的 container.reset(),保證每個測試案例的 DI 環境乾淨。

實際應用場景

場景 為何適合 Clean Architecture
大型電商平台(商品、訂單、付款) 多個業務子系統相互獨立,換用不同的支付服務商時,只需改 PaymentGateway 實作。
SaaS 多租戶系統 透過 Domain 內的 Tenant Entity,將租戶相關規則集中管理,框架層只負責路由與驗證。
微服務(User Service、Auth Service) 每個微服務只保有自己的 Domain 與 Use Cases,基礎建設(訊息佇列、資料庫)可獨立替換。
需要高可測試性(金融、醫療) 依賴倒置讓所有業務規則在單元測試中不必啟動 Express 或連接資料庫,提升測試速度與安全性。
長期維護的內部系統 隨著開發人員輪替,清晰的層級劃分降低新手上手成本,減少因不熟悉框架而破壞核心業務邏輯的風險。

總結

Clean Architecture 為 Express + TypeScript 專案提供了一條「業務核心永遠不會被外部技術所污染」的路徑。透過 四層劃分、依賴倒置 與 DI 容器,我們可以:

  • 清晰分離:Domain、Use Cases、Interface Adapters、Framework,各司其職。
  • 易於測試:Use Cases 只依賴抽象介面,單元測試僅需 mock。
  • 彈性升級:換框架或資料庫,只需要改寫最外層的實作,內層業務不受影響。

從本文的 使用者註冊 範例,我們看到每一層的程式碼都保持簡潔且專注。未來若要加入 驗證、郵件通知、OAuth 等功能,只需要在 Use Cases 或 Interface Adapters 新增相應的服務,既不破壞既有結構,也不增加測試負擔。

把 Clean Architecture 當作專案的「骨骼」,讓你的 Express 應用在面對需求變更、技術升級時,依舊能保持 健壯、可讀、可維護。祝開發順利,期待看到你把這套架構落實在真實的產品中!