本文 AI 產出,尚未審核

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 容器,但可以透過簡單的工廠函式或使用 tsyringeinversify 等套件。以下示範最輕量的手寫方式:

// 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 的 reqres


範例 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.tsenum 來集中管理路由、狀態碼等常數。
依賴直接 new 測試時難以注入 mock 物件 採用 依賴注入(DI)或簡易工廠模式,讓測試可以傳入 stub/mock。
未使用 async/await 錯誤捕捉 Promise 錯誤會被吞掉 所有非同步函式都使用 async/await,並在 Controller 中使用 try/catch 或交給錯誤中介軟體。

其他建議

  1. 單元測試:對 Service 與 Repository 實作測試,使用 jest + ts-jest,透過 mock 取代實際 DB。
  2. 資料驗證:使用 class-validator + class-transformer 於 DTO(Data Transfer Object)層做驗證,保持 Controller 輕量。
  3. 環境變數管理:使用 dotenv,並在 config/ 目錄統一讀取,避免硬寫 DB 連線字串。
  4. 日誌:導入 winstonpino,在 Service 中記錄業務流程,方便除錯與監控。

實際應用場景

場景 為什麼需要分層 如何落實
電商平台的商品 API 商品資料會同時涉及庫存、價格、促銷規則等多個資料來源 Service 統合 ProductRepositoryInventoryRepositoryPromotionService,Controller 只回傳最終結果。
社交網站的貼文服務 貼文需要同時寫入資料庫、推送通知、更新快取 在 Service 中呼叫 PostRepositoryNotificationServiceCacheService,保持每個功能模組獨立。
多租戶 SaaS 系統 每個租戶的資料庫連線可能不同 Repository 透過 DI 接收 tenantId,在建構時決定使用哪個 Prisma client,Service 不感知租戶細節。
資料分析平台的批次匯入 需要驗證、清洗、寫入多張表格,且錯誤必須回滾 以 Service 為中心,使用 Unit of Work(交易)在 Repository 層實作,確保一致性。

總結

分層架構是 提升 ExpressJS + TypeScript 專案可維護性、可測試性與可擴充性的關鍵。透過明確的 Controller → Service → Repository 流程,我們可以:

  • HTTP業務邏輯 完全分離,讓每層只關心自己的職責。
  • 利用 TypeScript 介面 定義清晰的合約,降低因型別錯誤導致的執行時 Bug。
  • 依賴注入 為基礎,輕鬆替換實作或在測試時注入 mock。
  • 統一 錯誤處理回應格式,提升 API 一致性與開發效率。

只要遵循本文的 目錄結構、程式碼範例與最佳實踐,即使是剛入門的開發者,也能快速打造出 乾淨、易維護且具備良好測試基礎 的完整 API 服務。祝開發順利,期待看到你用分層架構構建的下一個成功案例!