本文 AI 產出,尚未審核

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 – 統一回應格式與錯誤處理

為了讓前端團隊只要關注 dataerror 欄位,我們可以在 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 變得臃腫、測試困難。 把驗證抽成 MiddlewareDTO,使用 class-validator
錯誤直接在 Controller 回傳 不統一的錯誤格式會讓前端處理變複雜。 交給全域錯誤 Middleware,只在 Controller 中 next(err)
Service 直接寫死在 Controller 難以替換測試或未來的實作(例如改用微服務)。 依賴注入(建構子注入或 IoC Container)。
過度依賴 any 失去 TypeScript 靜態型別的好處。 使用 介面 (Interface)型別別名 (type) 以及 DTO,盡量避免 any

最佳實踐總結

  1. 單一職責:Controller 只做「請求 ↔︎ 服務」的橋樑。
  2. 統一回應/錯誤:使用 Wrapper 或全域 Middleware。
  3. 型別安全:DTO + class‑validator + 介面。
  4. 測試友善:建構子注入、避免直接引用 new
  5. 保持簡潔:每個方法不超過 30 行程式碼,過長就抽成 Service 或 Helper。

實際應用場景

場景 為什麼需要 Controller 示範程式片段
多版本 API(v1、v2) 各版本可能有不同的業務規則,但路由結構相同。 routes/v1/user.routes.tsroutes/v2/user.routes.ts 中分別引用 UserV1ControllerUserV2Controller
權限驗證(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 應用!