ExpressJS (TypeScript) – 使用 TypeScript 建立 Controllers
主題:分離邏輯 – Controller vs Service
簡介
在大型的 Node.js / Express 專案裡,程式碼的可維護性、可測試性 與 可擴充性 常常是決定專案成功與否的關鍵因素。若把所有業務邏輯直接寫在路由處理函式(Controller)裡,程式碼會迅速變得又長又雜,測試也變得困難。
將 Controller 與 Service 做明確的職責分離(Separation of Concerns)是業界廣泛採用的最佳實踐。Controller 只負責 HTTP 請求/回應的協調,而 Service 則負責 業務邏輯、資料處理與外部資源呼叫。在 TypeScript 的加持下,我們還可以透過型別系統把兩者的介面寫得更清晰,提升開發者的開發體驗與程式碼品質。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後帶出實務應用場景,完整示範如何在 Express + TypeScript 專案中正確劃分 Controller 與 Service。
核心概念
1. 什麼是 Controller?
- 職責:
- 解析
req(例如:body、params、query) - 呼叫對應的 Service 方法
- 處理 Service 回傳的結果,回傳適當的 HTTP 回應(status code、JSON、錯誤訊息)
- 解析
- 不應該:
- 包含資料庫查詢、商業規則、或大量的資料轉換邏輯
範例:下面的
UserController只做了參數驗證與回傳結果的工作。
// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/user.service';
export class UserController {
// 注入 Service,建議使用 DI(依賴注入)框架或手動注入
constructor(private readonly userService: UserService) {}
// GET /users/:id
async getUserById(req: Request, res: Response, next: NextFunction) {
try {
const id = Number(req.params.id);
if (isNaN(id)) {
return res.status(400).json({ message: 'Invalid user id' });
}
const user = await this.userService.findById(id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
return res.json(user);
} catch (err) {
next(err); // 交給全域錯誤處理中介軟體
}
}
}
2. 什麼是 Service?
職責:
- 處理 業務規則(例如:密碼雜湊、權限驗證)
- 與資料庫、第三方 API、訊息佇列等外部資源互動
- 回傳純粹的資料或錯誤,讓 Controller 決定如何形成 HTTP 回應
不應該:
- 直接操作
req、res、或next,因為這會讓 Service 變得與 Express 緊耦合
- 直接操作
範例:
UserService完全不認識 Express,僅專注於資料庫操作與商業邏輯。
// src/services/user.service.ts
import { PrismaClient, User } from '@prisma/client';
import bcrypt from 'bcrypt';
export class UserService {
private prisma = new PrismaClient();
async findById(id: number): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
async createUser(email: string, password: string): Promise<User> {
const hashed = await bcrypt.hash(password, 10);
return this.prisma.user.create({
data: { email, password: hashed },
});
}
// 其他業務邏輯,如驗證、統計等
}
3. 為什麼要分離?
| 好處 | 說明 |
|---|---|
| 可測試性 | Service 只依賴純函式或資料層,可使用 Jest、Mocha 等單元測試框架輕鬆測試。Controller 只測試路由與回傳行為。 |
| 可讀性 | 每個檔案的長度保持在 100 行以內,讓新加入的同事快速了解程式碼意圖。 |
| 可重用性 | Service 可以在 CLI、Cron、或其他微服務中直接呼叫,無需再包裝 Express。 |
| 職責單一 | 依照 SOLID 原則的 SRP(單一職責原則)設計,使未來改變需求時影響範圍最小。 |
4. 如何在 TypeScript 中定義介面(Interface)
使用介面可以讓 Controller 與 Service 之間的契約更明確,特別是當你要切換實作(例如:從 Prisma 換成 TypeORM)時,只要保證介面不變即可。
// src/services/interfaces/user.service.interface.ts
import { User } from '@prisma/client';
export interface IUserService {
findById(id: number): Promise<User | null>;
createUser(email: string, password: string): Promise<User>;
}
Controller 只依賴 IUserService:
// src/controllers/user.controller.ts
import { IUserService } from '../services/interfaces/user.service.interface';
export class UserController {
constructor(private readonly userService: IUserService) {}
// ... 其餘同上
}
5. 注入(Dependency Injection)方式
- 手動注入:在路由檔案中自行建立 Service 實例並傳入 Controller。
- DI 容器:使用
typedi、inversify、tsyringe等套件,讓框架自動管理生命週期。
以下示範 手動注入 的簡易寫法:
// src/routes/user.routes.ts
import { Router } from 'express';
import { UserController } from '../controllers/user.controller';
import { UserService } from '../services/user.service';
const router = Router();
const userService = new UserService(); // 建立 Service
const userController = new UserController(userService); // 注入
router.get('/:id', (req, res, next) => userController.getUserById(req, res, next));
export default router;
程式碼範例
下面提供 3 個完整的實務範例,分別展示 CRUD、錯誤處理與交易(Transaction)情境。每段程式碼均附上說明註解。
範例一:基本 CRUD(Create / Read)
// src/controllers/product.controller.ts
import { Request, Response, NextFunction } from 'express';
import { IProductService } from '../services/interfaces/product.service.interface';
export class ProductController {
constructor(private readonly productService: IProductService) {}
// POST /products
async create(req: Request, res: Response, next: NextFunction) {
try {
const { name, price } = req.body;
if (!name || typeof price !== 'number') {
return res.status(400).json({ message: 'Invalid payload' });
}
const product = await this.productService.create(name, price);
return res.status(201).json(product);
} catch (err) {
next(err);
}
}
// GET /products/:id
async getById(req: Request, res: Response, next: NextFunction) {
try {
const id = Number(req.params.id);
const product = await this.productService.findById(id);
if (!product) return res.status(404).json({ message: 'Product not found' });
return res.json(product);
} catch (err) {
next(err);
}
}
}
// src/services/product.service.ts
import { PrismaClient, Product } from '@prisma/client';
import { IProductService } from './interfaces/product.service.interface';
export class ProductService implements IProductService {
private prisma = new PrismaClient();
async create(name: string, price: number): Promise<Product> {
return this.prisma.product.create({ data: { name, price } });
}
async findById(id: number): Promise<Product | null> {
return this.prisma.product.findUnique({ where: { id } });
}
}
重點:Controller 只負責驗證與回傳,所有 DB 操作與商業規則都在 Service。
範例二:統一錯誤處理與自訂例外
// src/errors/app.error.ts
export class AppError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean;
constructor(message: string, statusCode = 500, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
// src/services/order.service.ts
import { AppError } from '../errors/app.error';
import { PrismaClient, Order, OrderItem } from '@prisma/client';
export class OrderService {
private prisma = new PrismaClient();
// 建立訂單,同時檢查庫存
async createOrder(userId: number, items: { productId: number; qty: number }[]): Promise<Order> {
return this.prisma.$transaction(async (tx) => {
// 檢查每項商品庫存
for (const item of items) {
const product = await tx.product.findUnique({ where: { id: item.productId } });
if (!product) throw new AppError(`Product ${item.productId} not found`, 404);
if (product.stock < item.qty) throw new AppError(`Insufficient stock for ${product.name}`, 400);
}
// 建立訂單
const order = await tx.order.create({ data: { userId, status: 'PENDING' } });
// 建立訂單明細
const orderItems: Promise<OrderItem>[] = items.map((it) =>
tx.orderItem.create({
data: {
orderId: order.id,
productId: it.productId,
quantity: it.qty,
price: 0, // 後續可加入價格計算
},
})
);
await Promise.all(orderItems);
return order;
});
}
}
// src/controllers/order.controller.ts
import { Request, Response, NextFunction } from 'express';
import { OrderService } from '../services/order.service';
import { AppError } from '../errors/app.error';
export class OrderController {
constructor(private readonly orderService: OrderService) {}
async placeOrder(req: Request, res: Response, next: NextFunction) {
try {
const userId = Number(req.user?.id); // 假設已經有 auth 中介軟體注入
const items = req.body.items;
if (!Array.isArray(items) || items.length === 0) {
throw new AppError('Order items required', 400);
}
const order = await this.orderService.createOrder(userId, items);
res.status(201).json(order);
} catch (err) {
next(err); // 交給全域錯誤處理器
}
}
}
說明:
AppError為自訂例外,讓 Service 能拋出業務層面的錯誤,而不必自行決定 HTTP status。- 全域錯誤中介軟體會根據
statusCode統一回傳 JSON 給前端。
範例三:使用 DI 容器(tsyringe)
// src/containers.ts
import 'reflect-metadata';
import { container } from 'tsyringe';
import { UserService } from './services/user.service';
import { IUserService } from './services/interfaces/user.service.interface';
container.register<IUserService>('IUserService', { useClass: UserService });
// src/controllers/auth.controller.ts
import { Request, Response, NextFunction } from 'express';
import { inject, injectable } from 'tsyringe';
import { IUserService } from '../services/interfaces/user.service.interface';
import jwt from 'jsonwebtoken';
@injectable()
export class AuthController {
constructor(@inject('IUserService') private readonly userService: IUserService) {}
async login(req: Request, res: Response, next: NextFunction) {
try {
const { email, password } = req.body;
const user = await this.userService.validateCredentials(email, password);
if (!user) return res.status(401).json({ message: 'Invalid credentials' });
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET!, { expiresIn: '1h' });
res.json({ token });
} catch (err) {
next(err);
}
}
}
// src/routes/auth.routes.ts
import { Router } from 'express';
import { container } from 'tsyringe';
import { AuthController } from '../controllers/auth.controller';
const router = Router();
const authController = container.resolve(AuthController);
router.post('/login', (req, res, next) => authController.login(req, res, next));
export default router;
優點:使用 DI 容器後,測試 時只要在測試環境替換
IUserService的實作即可,無需改動 Controller 程式碼。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方式 / 最佳實踐 |
|---|---|---|
| 把資料庫查詢寫在 Controller | 初學者習慣直接在路由裡寫 await prisma.xxx,結果控制器變得又長又雜。 |
將所有 DB 操作抽到 Service,控制器僅負責 req/res。 |
Service 依賴 Express 物件 (req, res, next) |
會讓 Service 與框架耦合,測試困難且無法在非 HTTP 場景重用。 | 保持 Service 純粹,只接受普通參數與返回值。 |
| 錯誤拋出與 HTTP 狀態碼混用 | Service 直接回傳 res.status(400).json(...),失去錯誤統一處理的好處。 |
使用自訂錯誤類別(如 AppError),在全域錯誤中介軟體中統一轉換為 HTTP 回應。 |
| 依賴注入忘記註冊 | 使用 DI 時若忘記在容器註冊,執行時會拋出 Token not found。 |
在程式啟動入口(app.ts)就載入 containers.ts,確保所有介面都有對應實作。 |
| Service 方法過長 | 一個 Service 方法同時完成驗證、DB、外部 API,難以測試。 | 將 Service 再細分成子 Service 或 使用 Helper/Utility,保持每個方法的職責單一(< 30 行為佳)。 |
最佳實踐清單
- 介面化 Service:
IUserService、IProductService等,讓實作與使用解耦。 - 統一錯誤類別:
AppError+ 全域錯誤處理中介軟體(app.use(errorHandler))。 - 使用 DTO(Data Transfer Object):在 Controller 進行 輸入驗證(可配合
class-validator、zod),避免 Service 收到不合法資料。 - 寫單元測試:
- Service:mock 資料庫(如
jest-mock-extended)測試業務邏輯。 - Controller:使用
supertest搭配 Express 實例測試路由行為。
- Service:mock 資料庫(如
- 保持一致的回傳結構:如
{ success: true, data: ..., message?: string },前端可以統一處理。
實際應用場景
1. 電子商務平台
- Controller:處理購物車、結帳、訂單查詢等 HTTP 請求。
- Service:
- CartService:計算總金額、驗證庫存。
- OrderService:使用交易(transaction)保證訂單與庫存同步更新。
- PaymentService:呼叫第三方支付 API。
這樣的分層使得 支付流程(PaymentService)可以在 非 HTTP 的背景工作(例如:RabbitMQ 消費者)中直接使用,避免重複程式碼。
2. 多租戶 SaaS 系統
- Controller:根據 JWT 中的 tenantId 解析出使用者所屬租戶。
- Service:在每個 Service 方法內部根據 tenantId 設定資料庫連線或篩選條件。
- 好處:若未來要改成 資料庫分片(sharding),只需要在 Service 層調整連線邏輯,Controller 完全不受影響。
3. 內部管理系統(Admin Dashboard)
- Controller:提供 CRUD API,僅負責驗證使用者權限。
- Service:封裝 RBAC(Role‑Based Access Control)邏輯與審計(audit)紀錄。
- 優勢:審計功能可以在 Service 中統一實作,所有 Controller 都自動得到審計紀錄,而不需要在每個路由重複寫程式。
總結
在 Express + TypeScript 專案中,將 Controller 與 Service 明確分離是提升程式碼品質的關鍵一步。
- Controller 只負責 HTTP 請求/回應 的協調與簡易驗證。
- Service 承擔 業務邏輯、資料處理、外部資源呼叫,保持與框架解耦。
- 透過 介面 (Interface)、自訂錯誤類別、依賴注入 (DI) 等技術,可讓程式碼更 可測試、可重用、易維護。
把握這些概念與實作技巧,無論是小型的 API 專案,或是大型的微服務架構,都能以乾淨、可擴充的方式快速開發與迭代。祝你寫程式開心,專案順利!