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。 |
最佳實踐
- 保持層級純粹:每層只做自己該做的事。
- 使用 TypeScript 的型別系統:介面(Interface)與 DTO 能有效防止錯誤傳遞。
- 建立共用的錯誤類別:
BusinessRuleError,InfrastructureError,讓上層能根據錯誤類型回傳正確 HTTP 狀態碼。 - 在專案根目錄放置
src/,以@為別名:tsconfig.json中設定paths,提升 import 可讀性。 - 自動化測試:使用
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 應用在面對需求變更、技術升級時,依舊能保持 健壯、可讀、可維護。祝開發順利,期待看到你把這套架構落實在真實的產品中!