本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 使用 TypeScript 建立 Controllers

主題:分離邏輯 – Controller vs Service


簡介

在大型的 Node.js / Express 專案裡,程式碼的可維護性可測試性可擴充性 常常是決定專案成功與否的關鍵因素。若把所有業務邏輯直接寫在路由處理函式(Controller)裡,程式碼會迅速變得又長又雜,測試也變得困難。

ControllerService 做明確的職責分離(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 回應
  • 不應該

    • 直接操作 reqres、或 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)

使用介面可以讓 ControllerService 之間的契約更明確,特別是當你要切換實作(例如:從 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 容器:使用 typediinversifytsyringe 等套件,讓框架自動管理生命週期。

以下示範 手動注入 的簡易寫法:

// 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); // 交給全域錯誤處理器
    }
  }
}

說明

  1. AppError 為自訂例外,讓 Service 能拋出業務層面的錯誤,而不必自行決定 HTTP status。
  2. 全域錯誤中介軟體會根據 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 行為佳)。

最佳實踐清單

  1. 介面化 ServiceIUserServiceIProductService 等,讓實作與使用解耦。
  2. 統一錯誤類別AppError + 全域錯誤處理中介軟體(app.use(errorHandler))。
  3. 使用 DTO(Data Transfer Object):在 Controller 進行 輸入驗證(可配合 class-validatorzod),避免 Service 收到不合法資料。
  4. 寫單元測試
    • Service:mock 資料庫(如 jest-mock-extended)測試業務邏輯。
    • Controller:使用 supertest 搭配 Express 實例測試路由行為。
  5. 保持一致的回傳結構:如 { 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 專案中,將 ControllerService 明確分離是提升程式碼品質的關鍵一步。

  • Controller 只負責 HTTP 請求/回應 的協調與簡易驗證。
  • Service 承擔 業務邏輯、資料處理、外部資源呼叫,保持與框架解耦。
  • 透過 介面 (Interface)自訂錯誤類別依賴注入 (DI) 等技術,可讓程式碼更 可測試、可重用、易維護

把握這些概念與實作技巧,無論是小型的 API 專案,或是大型的微服務架構,都能以乾淨、可擴充的方式快速開發與迭代。祝你寫程式開心,專案順利!