ExpressJS (TypeScript) – 實戰專案:完整 API 服務
主題:分層架構建置
簡介
在實務開發中,API 服務往往會隨著需求成長而變得越來越複雜。若把所有程式碼都堆在同一個檔案或目錄,未來的維護、測試、擴充都會變得困難。分層(Layered)架構是一種將系統職責切分為 表示層、業務層、資料存取層 的設計方式,能讓程式碼結構更清晰、職責更單一,亦方便單元測試與團隊協作。
本篇文章將以 ExpressJS + TypeScript 為基礎,說明如何在一個完整的 API 專案中落實分層架構,並提供可直接套用的範例程式碼。即使你是剛接觸 Node.js 的新手,只要掌握概念與範例,就能快速建立可維護的服務。
核心概念
1. 分層的基本原則
| 層級 | 責任 | 常見檔案/目錄 |
|---|---|---|
| 表示層 (Controller / Router) | 接收 HTTP 請求、回傳 HTTP 回應,僅負責參數驗證與呼叫業務層 | src/controllers/, src/routes/ |
| 業務層 (Service) | 處理核心商業邏輯、協調多個資料來源,保持與 HTTP 無關 | src/services/ |
| 資料存取層 (Repository / Model) | 與資料庫或外部 API 互動,提供 CRUD 方法 | src/repositories/, src/models/ |
關鍵:每層只與下層耦合,上層不直接觸碰資料庫或底層實作,這樣才能在未來替換資料來源或改寫業務邏輯而不影響其他層。
2. TypeScript 的型別優勢
使用 TypeScript 可以在 編譯階段捕捉錯誤,尤其在分層架構中,介面的定義(Interface)讓每層的輸入/輸出都明確。例如:
// src/models/User.ts
export interface User {
id: number;
name: string;
email: string;
}
// src/repositories/IUserRepository.ts
import { User } from "./User";
export interface IUserRepository {
findById(id: number): Promise<User | null>;
create(user: Omit<User, "id">): Promise<User>;
}
上層的 Service 僅依賴 IUserRepository,而不關心實作細節。
3. 建立目錄結構
以下是一個典型的專案佈局(省略測試與設定檔):
src/
├─ controllers/
│ └─ user.controller.ts
├─ routes/
│ └─ user.route.ts
├─ services/
│ └─ user.service.ts
├─ repositories/
│ ├─ user.repository.ts
│ └─ IUserRepository.ts
├─ models/
│ └─ User.ts
├─ middlewares/
│ └─ errorHandler.ts
└─ app.ts
這樣的結構可以讓 app.ts 只負責組裝 Express、載入路由與全域中介軟體。
4. 依賴注入(Dependency Injection)
在 Node.js 中沒有像 .NET 那樣的內建 DI 容器,但可以透過簡單的工廠函式或使用 tsyringe、inversify 等套件。以下示範最輕量的手寫方式:
// src/container.ts
import { IUserRepository } from "./repositories/IUserRepository";
import { UserRepository } from "./repositories/user.repository";
import { UserService } from "./services/user.service";
export const userRepository: IUserRepository = new UserRepository();
export const userService = new UserService(userRepository);
在 Controller 中直接匯入已建好的 userService,即可避免在每個檔案中自行 new。
5. 錯誤處理與回傳格式
分層架構的好處之一是 錯誤可以在服務層拋出,統一在中介軟體捕捉,保持 Controller 的簡潔:
// src/services/user.service.ts
import { IUserRepository } from "../repositories/IUserRepository";
import { NotFoundError } from "../errors";
export class UserService {
constructor(private readonly repo: IUserRepository) {}
async getUser(id: number) {
const user = await this.repo.findById(id);
if (!user) {
throw new NotFoundError(`User with id ${id} not found`);
}
return user;
}
}
// src/middlewares/errorHandler.ts
import { Request, Response, NextFunction } from "express";
export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction) {
const status = err.statusCode ?? 500;
const message = err.message ?? "Internal Server Error";
res.status(status).json({ error: message });
}
程式碼範例
下面提供 五個實用範例,涵蓋從路由到資料庫操作的完整流程。所有檔案均使用 TypeScript,且加上說明註解。
範例 1:User Model(src/models/User.ts)
// src/models/User.ts
export interface User {
/** 資料庫自動產生的主鍵 */
id: number;
/** 使用者名稱 */
name: string;
/** 電子郵件,需唯一 */
email: string;
}
說明:介面僅描述資料形態,讓 TypeScript 在編譯時即能檢查屬性是否正確。
範例 2:Repository 實作(src/repositories/user.repository.ts)
// src/repositories/user.repository.ts
import { IUserRepository } from "./IUserRepository";
import { User } from "../models/User";
// 假設使用 Prisma 作為 ORM
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export class UserRepository implements IUserRepository {
async findById(id: number): Promise<User | null> {
return await prisma.user.findUnique({ where: { id } });
}
async create(user: Omit<User, "id">): Promise<User> {
return await prisma.user.create({ data: user });
}
// 其他 CRUD 方法可依需求擴充
}
重點:Repository 僅負責與資料庫互動,回傳的型別皆符合
User介面。
範例 3:Service(src/services/user.service.ts)
// src/services/user.service.ts
import { IUserRepository } from "../repositories/IUserRepository";
import { User } from "../models/User";
import { BadRequestError, NotFoundError } from "../errors";
export class UserService {
constructor(private readonly repo: IUserRepository) {}
/** 取得單一使用者 */
async getUser(id: number): Promise<User> {
const user = await this.repo.findById(id);
if (!user) {
throw new NotFoundError(`User with id ${id} 不存在`);
}
return user;
}
/** 建立新使用者,包含簡易驗證 */
async createUser(payload: { name: string; email: string }): Promise<User> {
if (!payload.name || !payload.email) {
throw new BadRequestError("Name 與 Email 為必填欄位");
}
// 這裡可以加入更複雜的商業規則,例如 Email 格式驗證
return await this.repo.create(payload);
}
}
說明:Service 只處理「業務」相關的邏輯,不直接操作 Express 的
req、res。
範例 4:Controller(src/controllers/user.controller.ts)
// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from "express";
import { userService } from "../container";
export class UserController {
/** GET /users/:id */
static async getUser(req: Request, res: Response, next: NextFunction) {
try {
const id = Number(req.params.id);
const user = await userService.getUser(id);
res.json({ data: user });
} catch (err) {
next(err);
}
}
/** POST /users */
static async createUser(req: Request, res: Response, next: NextFunction) {
try {
const newUser = await userService.createUser(req.body);
res.status(201).json({ data: newUser });
} catch (err) {
next(err);
}
}
}
要點:Controller 只做「參數取得 + 呼叫 Service + 回傳結果」的工作,錯誤交給全域錯誤中介軟體處理。
範例 5:Route 組合(src/routes/user.route.ts)
// src/routes/user.route.ts
import { Router } from "express";
import { UserController } from "../controllers/user.controller";
const router = Router();
/** 取得單一使用者 */
router.get("/:id", UserController.getUser);
/** 建立新使用者 */
router.post("/", UserController.createUser);
export default router;
說明:路由檔案只負責把 HTTP 方法與 Controller 綁定,保持單一職責。
範例 6:Express 應用程式入口(src/app.ts)
// src/app.ts
import express from "express";
import userRouter from "./routes/user.route";
import { errorHandler } from "./middlewares/errorHandler";
const app = express();
app.use(express.json()); // 解析 JSON body
// 掛載路由
app.use("/api/users", userRouter);
// 全域錯誤處理
app.use(errorHandler);
export default app;
小技巧:在
app.ts中不直接listen,方便在測試環境中以supertest直接呼叫。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 最佳實踐 |
|---|---|---|
| Controller 直接寫 DB 邏輯 | 會導致程式碼耦合、測試困難 | 保持 Controller 薄,所有 DB 操作放在 Repository,所有業務規則放在 Service。 |
| 缺乏型別介面 | JavaScript 的靈活性易產生隱藏錯誤 | 使用 TypeScript Interface 為每層的輸入/輸出建立合約,編譯時即能捕捉錯誤。 |
| 錯誤未統一處理 | 每個 Controller 都寫 try/catch,程式碼冗長 |
實作 全域錯誤中介軟體,在 Service 中拋出自訂錯誤類別(如 BadRequestError),統一格式回傳。 |
| 硬編碼路由字串 | 改變路徑時需要逐一搜尋替換 | 使用 constants.ts 或 enum 來集中管理路由、狀態碼等常數。 |
| 依賴直接 new | 測試時難以注入 mock 物件 | 採用 依賴注入(DI)或簡易工廠模式,讓測試可以傳入 stub/mock。 |
| 未使用 async/await 錯誤捕捉 | Promise 錯誤會被吞掉 | 所有非同步函式都使用 async/await,並在 Controller 中使用 try/catch 或交給錯誤中介軟體。 |
其他建議
- 單元測試:對 Service 與 Repository 實作測試,使用
jest+ts-jest,透過 mock 取代實際 DB。 - 資料驗證:使用
class-validator+class-transformer於 DTO(Data Transfer Object)層做驗證,保持 Controller 輕量。 - 環境變數管理:使用
dotenv,並在config/目錄統一讀取,避免硬寫 DB 連線字串。 - 日誌:導入
winston或pino,在 Service 中記錄業務流程,方便除錯與監控。
實際應用場景
| 場景 | 為什麼需要分層 | 如何落實 |
|---|---|---|
| 電商平台的商品 API | 商品資料會同時涉及庫存、價格、促銷規則等多個資料來源 | Service 統合 ProductRepository、InventoryRepository、PromotionService,Controller 只回傳最終結果。 |
| 社交網站的貼文服務 | 貼文需要同時寫入資料庫、推送通知、更新快取 | 在 Service 中呼叫 PostRepository、NotificationService、CacheService,保持每個功能模組獨立。 |
| 多租戶 SaaS 系統 | 每個租戶的資料庫連線可能不同 | Repository 透過 DI 接收 tenantId,在建構時決定使用哪個 Prisma client,Service 不感知租戶細節。 |
| 資料分析平台的批次匯入 | 需要驗證、清洗、寫入多張表格,且錯誤必須回滾 | 以 Service 為中心,使用 Unit of Work(交易)在 Repository 層實作,確保一致性。 |
總結
分層架構是 提升 ExpressJS + TypeScript 專案可維護性、可測試性與可擴充性的關鍵。透過明確的 Controller → Service → Repository 流程,我們可以:
- 把 HTTP 與 業務邏輯 完全分離,讓每層只關心自己的職責。
- 利用 TypeScript 介面 定義清晰的合約,降低因型別錯誤導致的執行時 Bug。
- 以 依賴注入 為基礎,輕鬆替換實作或在測試時注入 mock。
- 統一 錯誤處理 與 回應格式,提升 API 一致性與開發效率。
只要遵循本文的 目錄結構、程式碼範例與最佳實踐,即使是剛入門的開發者,也能快速打造出 乾淨、易維護且具備良好測試基礎 的完整 API 服務。祝開發順利,期待看到你用分層架構構建的下一個成功案例!