本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 架構進階模式

Clean Architecture / Layered Architecture


簡介

Node.jsExpress 生態系中,快速開發的便利性常讓開發者直接把所有程式碼塞在 routercontroller 裡。雖然這樣可以在短時間內完成 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 容器(如 tsyringeinversify)在啟動時把外層實作注入到內層介面,保持 低耦合


程式碼範例

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

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.bodyres.json() 會讓業務邏輯與 Express 緊耦合。 保持 Use Case 純粹,只接受 DTO,回傳 Domain Entity。
依賴倒置失敗:直接 new Repository 測試時難以 mock,且換資料庫需改所有呼叫處。 使用 DI 容器Factory 注入介面。
Entity 內放太多基礎 CRUD 使 Entity 變成「資料表映射」而非業務模型。 只保留 行為(如驗證、計算),資料存取交給 Repository。
錯誤傳遞不一致 有的拋 Error、有的回傳 null,導致上層需要多種判斷。 統一例外類型(如自訂 DomainErrorApplicationError),在 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,配合 tsyringecontainer.reset(),保證每個測試案例的 DI 環境乾淨。

實際應用場景

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

總結

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

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

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

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