ExpressJS (TypeScript) – 使用 TypeScript 建立 Controllers
Controller 架構模式
簡介
在 Node.js 生態系統中,Express 是最常被採用的 Web 框架,而 TypeScript 則提供了靜態型別、編譯時錯誤檢查與自動補完等開發利器。將兩者結合後,我們可以用更安全、更易維護的方式撰寫 API。
在大型專案裡,路由 (Route) 與業務邏輯 (Business Logic) 若混雜在同一個檔案,會讓程式碼變得難以閱讀、測試與重構。Controller 架構模式 把「請求的進入點」與「實際要執行的動作」分離,讓每個功能都有自己的職責 (Single Responsibility)。本文將說明如何在 Express + TypeScript 中實作 Controller,並提供實務範例、常見陷阱與最佳實踐,幫助初學者快速上手、讓中階開發者提升專案品質。
核心概念
1. Controller 是什麼?
Controller:負責接收 HTTP 請求、驗證參數、呼叫 Service/Repository,最後回傳結果或錯誤。
- 不直接操作資料庫,這是 Service 或 Repository 的工作。
- 不處理跨域、日誌、錯誤格式化,這些通常交給 Middleware 處理。
好處
- 職責分離:路由只負責路徑與 HTTP 方法,Controller 處理業務。
- 易於單元測試:可以直接對 Controller 方法呼叫,模擬 Request/Response。
- 可重用:同一個 Controller 可以被多個路由或版本共用。
2. 基本檔案結構(建議)
src/
├─ controllers/
│ ├─ user.controller.ts
│ └─ auth.controller.ts
├─ services/
│ ├─ user.service.ts
│ └─ auth.service.ts
├─ repositories/
│ └─ user.repository.ts
├─ routes/
│ └─ user.routes.ts
├─ middlewares/
│ └─ validate.ts
├─ app.ts
└─ server.ts
- controllers/:放所有 Controller 類別。
- services/:封裝業務邏輯。
- repositories/:負責資料存取(ORM、原始 SQL、外部 API)。
- routes/:只寫路由與 Controller 的對應。
3. 建立基礎 Controller 類別
使用 抽象類別 或 介面 定義共用行為,讓每個 Controller 都遵循同一套簽名。
// src/controllers/base.controller.ts
import { Request, Response, NextFunction } from 'express';
/**
* 所有 Controller 必須實作的介面
*/
export interface IBaseController {
/**
* 依照需求自行實作 CRUD 方法
*/
getAll?(req: Request, res: Response, next: NextFunction): Promise<void>;
getById?(req: Request, res: Response, next: NextFunction): Promise<void>;
create?(req: Request, res: Response, next: NextFunction): Promise<void>;
update?(req: Request, res: Response, next: NextFunction): Promise<void>;
delete?(req: Request, res: Response, next: NextFunction): Promise<void>;
}
技巧:在
tsconfig.json中開啟"strict": true,確保每個 Controller 都正確實作介面。
4. 範例 1 – 基本的 UserController
// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { IBaseController } from './base.controller';
import { UserService } from '../services/user.service';
export class UserController implements IBaseController {
private readonly userService = new UserService();
/** 取得所有使用者 */
async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const users = await this.userService.findAll();
res.json({ data: users });
} catch (err) {
next(err); // 交給全域錯誤處理 Middleware
}
}
/** 取得單筆使用者 */
async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const id = Number(req.params.id);
const user = await this.userService.findById(id);
if (!user) {
res.status(404).json({ message: 'User not found' });
return;
}
res.json({ data: user });
} catch (err) {
next(err);
}
}
/** 新增使用者 */
async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const newUser = await this.userService.create(req.body);
res.status(201).json({ data: newUser });
} catch (err) {
next(err);
}
}
}
說明
- 每個方法都 使用 async/await,並在
catch中呼叫next(err),讓錯誤統一由全域 Middleware 處理。UserService只負責業務邏輯,Controller 僅做「資料搬運」與「回應格式化」。
5. 範例 2 – 使用 DTO (Data Transfer Object) 進行參數驗證
// src/dtos/create-user.dto.ts
import { IsEmail, IsString, Length } from 'class-validator';
export class CreateUserDto {
@IsEmail({}, { message: 'Email 格式不正確' })
email!: string;
@IsString()
@Length(6, 20, { message: '密碼長度需在 6~20 個字元' })
password!: string;
@IsString()
name!: string;
}
// src/middlewares/validate.ts
import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { Request, Response, NextFunction } from 'express';
/**
* 產生驗證 Middleware
*/
export const validateDto = (dtoClass: any) => {
return async (req: Request, res: Response, next: NextFunction) => {
const dtoObj = plainToInstance(dtoClass, req.body);
const errors: ValidationError[] = await validate(dtoObj);
if (errors.length > 0) {
const messages = errors
.map(err => Object.values(err.constraints || {}))
.flat();
res.status(400).json({ errors: messages });
return;
}
// 把驗證過的物件掛在 req.body 上,後續 Controller 可直接使用
req.body = dtoObj;
next();
};
};
// src/controllers/auth.controller.ts
import { Request, Response, NextFunction } from 'express';
import { CreateUserDto } from '../dtos/create-user.dto';
import { validateDto } from '../middlewares/validate';
import { AuthService } from '../services/auth.service';
export class AuthController {
private readonly authService = new AuthService();
/** 註冊新使用者 */
register = [
// 先跑驗證 Middleware
validateDto(CreateUserDto),
// 再執行實際的 Controller 方法
async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await this.authService.register(req.body);
res.status(201).json({ data: user });
} catch (err) {
next(err);
}
},
];
}
重點
- DTO + class‑validator 讓參數驗證變成可重用、型別安全 的程式碼。
- 在路由中直接使用
AuthController.register(陣列形式)即可一次掛上多個 Middleware。
6. 範例 3 – 結合依賴注入(DI)與測試友善的設計
在測試環境下,我們常希望 替換 Service 為 mock 物件。透過建構子注入(Constructor Injection)可以輕鬆做到。
// src/controllers/product.controller.ts
import { Request, Response, NextFunction } from 'express';
import { IProductService } from '../services/product.service.interface';
export class ProductController {
// 透過建構子注入 Service,預設使用真實實作
constructor(private readonly productService: IProductService) {}
/** 取得商品列表 */
async list(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const products = await this.productService.getAll();
res.json({ data: products });
} catch (err) {
next(err);
}
}
}
// src/services/product.service.interface.ts
export interface IProductService {
getAll(): Promise<any[]>;
getById(id: number): Promise<any | null>;
}
// src/services/product.service.ts
import { IProductService } from './product.service.interface';
export class ProductService implements IProductService {
async getAll(): Promise<any[]> {
// 假設使用 Prisma / TypeORM 讀取資料庫
return []; // 省略實作
}
async getById(id: number): Promise<any | null> {
return null;
}
}
在路由中注入:
// src/routes/product.routes.ts
import { Router } from 'express';
import { ProductController } from '../controllers/product.controller';
import { ProductService } from '../services/product.service';
const router = Router();
const productController = new ProductController(new ProductService());
router.get('/', productController.list.bind(productController));
export default router;
測試範例(使用 Jest):
// tests/product.controller.spec.ts
import { Request, Response, NextFunction } from 'express';
import { ProductController } from '../src/controllers/product.controller';
import { IProductService } from '../src/services/product.service.interface';
describe('ProductController', () => {
const mockService: IProductService = {
getAll: jest.fn().mockResolvedValue([{ id: 1, name: '測試商品' }]),
getById: jest.fn(),
};
const controller = new ProductController(mockService);
it('should return product list', async () => {
const req = {} as Request;
const res = {
json: jest.fn(),
} as unknown as Response;
const next = jest.fn() as NextFunction;
await controller.list(req, res, next);
expect(res.json).toBeCalledWith({ data: [{ id: 1, name: '測試商品' }] });
});
});
要點
- 使用
bind(this)或 箭頭函式 確保this正確指向。- 依賴注入讓 Controller 本身不需要知道 Service 的實作細節,測試時只要提供符合介面的 mock 即可。
7. 範例 4 – 統一回應格式與錯誤處理
為了讓前端團隊只要關注 data 或 error 欄位,我們可以在 Controller 中使用 Response Wrapper。
// src/utils/api-response.ts
export const success = (data: any, message = 'Success') => ({
status: 'ok',
message,
data,
});
export const fail = (error: any, code = 500, message = 'Error') => ({
status: 'error',
code,
message,
error,
});
// src/controllers/order.controller.ts
import { Request, Response, NextFunction } from 'express';
import { OrderService } from '../services/order.service';
import { success, fail } from '../utils/api-response';
export class OrderController {
private readonly orderService = new OrderService();
async create(req: Request, res: Response, next: NextFunction) {
try {
const order = await this.orderService.create(req.body);
res.status(201).json(success(order, 'Order created'));
} catch (err) {
// 若錯誤已經是自訂的 HttpError,可直接使用其屬性
if (err instanceof HttpError) {
res.status(err.statusCode).json(fail(err.message, err.statusCode));
} else {
next(err); // 交給全域錯誤 Middleware
}
}
}
}
全域錯誤 Middleware(app.ts):
// src/middlewares/error-handler.ts
import { Request, Response, NextFunction } from 'express';
export const errorHandler = (
err: any,
_req: Request,
res: Response,
_next: NextFunction,
) => {
console.error(err); // 可以再整合 log 系統
const status = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({
status: 'error',
code: status,
message,
});
};
常見陷阱與最佳實踐
| 常見問題 | 為什麼會發生 | 解決方式 / Best Practice |
|---|---|---|
Controller 方法忘記 async |
直接回傳 Promise,Express 不會自動捕獲錯誤。 | 務必使用 async/await,或在最外層包裝 catchAsync 之類的工具函式。 |
this 失效 |
把方法直接傳給 Router (router.get('/', controller.getAll)) 時,this 會變成 undefined。 |
使用 .bind(controller) 或 箭頭函式(router.get('/', (req,res,next)=>controller.getAll(req,res,next)))。 |
| 參數驗證寫在 Controller | 造成 Controller 變得臃腫、測試困難。 | 把驗證抽成 Middleware 或 DTO,使用 class-validator。 |
| 錯誤直接在 Controller 回傳 | 不統一的錯誤格式會讓前端處理變複雜。 | 交給全域錯誤 Middleware,只在 Controller 中 next(err)。 |
| Service 直接寫死在 Controller | 難以替換測試或未來的實作(例如改用微服務)。 | 依賴注入(建構子注入或 IoC Container)。 |
過度依賴 any |
失去 TypeScript 靜態型別的好處。 | 使用 介面 (Interface)、型別別名 (type) 以及 DTO,盡量避免 any。 |
最佳實踐總結:
- 單一職責:Controller 只做「請求 ↔︎ 服務」的橋樑。
- 統一回應/錯誤:使用 Wrapper 或全域 Middleware。
- 型別安全:DTO + class‑validator + 介面。
- 測試友善:建構子注入、避免直接引用
new。 - 保持簡潔:每個方法不超過 30 行程式碼,過長就抽成 Service 或 Helper。
實際應用場景
| 場景 | 為什麼需要 Controller | 示範程式片段 |
|---|---|---|
| 多版本 API(v1、v2) | 各版本可能有不同的業務規則,但路由結構相同。 | 在 routes/v1/user.routes.ts 與 routes/v2/user.routes.ts 中分別引用 UserV1Controller、UserV2Controller。 |
| 權限驗證(RBAC) | 不同路由需要不同的權限檢查。 | 在 Router 中先掛 authMiddleware,再呼叫 UserController.update。 |
| 大型電商平台 | 商品、訂單、庫存等功能繁多,必須保持代碼可維護。 | 每個領域(Product、Order、Inventory)都有自己的 Controller、Service、Repository。 |
| 微服務與聚合網關 | 網關僅負責轉發請求,真正的業務在微服務內。 | 在 Gateway 中的 Controller 僅呼叫 httpClient 轉發,錯誤仍交給全域 Middleware。 |
| 即時通知(WebSocket + REST) | 同時提供 HTTP API 與即時推送。 | REST Controller 處理 CRUD,WebSocket Service 處理即時事件,兩者透過 Event Bus 溝通。 |
總結
使用 TypeScript 重構 Express 應用程式的 Controller 架構模式,不只是讓程式碼更整潔,更能提升:
- 可讀性:路由、驗證、業務、資料層分離。
- 可測試性:單元測試只需要 mock Service,無需啟動整個伺服器。
- 可擴充性:未來加入新功能或改變底層實作,只要調整對應層級即可。
本文從概念說明、檔案結構、實作範例、常見陷阱到實務場景,提供了完整的藍圖。只要遵守 單一職責、型別安全、統一錯誤處理 的原則,即可在專案中快速落地、減少維護成本,讓前後端協作更加順暢。祝開發順利,期待看到你用 Controller 實踐出更優雅的 Express + TypeScript 應用!