Express 不管你怎麼放

Rails 有 app/models/, app/controllers/, app/views/。Django 有 models.py, views.py, urls.py。NestJS 有 modules、強制你用 CLI 生成。

Express 什麼都沒規定。一個 index.js 就能跑。

這讓 Express 初期很快,但隨著功能增加,每個人的 src/ 結構都長得不一樣,onboarding 新人要先花一天搞清楚「這個東西放哪裡」。

Proto 做了明確的選擇,而且每個選擇都有具體理由。


完整結構一覽

src/
├── app.ts                 # Express 實例建立 + 呼叫 bootstrap
├── bin/
│   └── www.ts             # HTTP server 啟動 + SIGTERM 處理
├── bootstrap/             # 啟動編排層(6 步驟序列)
│   ├── index.ts           # 主序列
│   ├── middleware.ts
│   ├── routes.ts
│   ├── errorHandlers.ts
│   ├── database.ts
│   ├── redis.ts
│   └── workers.ts
├── app/                   # 應用程式本體(layer-based)
│   ├── bases/             # 4 個 Base 類別
│   ├── configs/           # 14 個 config 檔(見上一篇)
│   ├── controllers/       # HTTP 入出層
│   ├── services/          # 商業邏輯層
│   ├── repositories/      # 資料存取層
│   ├── models/            # ORM model 定義
│   ├── middlewares/       # 自訂 middleware(含 auth/)
│   ├── exceptions/        # AppError + error handlers
│   ├── validators/        # Request schema 定義
│   ├── enums/             # 常數定義
│   ├── utils/             # 工具函式(含 logger)
│   ├── cache/             # Cache 抽象層
│   ├── queues/            # Bull queue 定義
│   ├── rbac/              # Role + permission 管理
│   ├── realtime/          # WebSocket
│   ├── search/            # Elasticsearch 整合
│   ├── storage/           # 檔案上傳 driver
│   ├── notifications/     # Email / push
│   └── events/            # Domain events
├── routes/
│   └── api/               # Route 定義(URL wiring)
├── locales/
│   ├── en/
│   └── zh/
├── types/                 # TypeScript 型別定義
└── database/
    ├── migrations/
    └── seeders/

第一個不明顯的拆分:app.ts vs bin/www.ts

app.ts 只做一件事:建立 Express 實例、呼叫 bootstrap:

// src/app.ts
import express from 'express';
import bootstrap from './bootstrap';
 
const app = express();
bootstrap(app).catch((err) => { process.exit(1); });
 
export default app;

它不呼叫 app.listen()

HTTP server 的啟動是 bin/www.ts 的責任:

// src/bin/www.ts
import app from '../app';
import http from 'http';
 
const server = http.createServer(app);
server.listen(port, () => { /* ... */ });
 
process.on('SIGTERM', async () => {
  // graceful shutdown
  server.close();
  await db.close();
  await redis.quit();
  process.exit(0);
});

為什麼要這樣拆?

測試。在 integration test 裡,你想要 import app 然後用 supertest 直接發請求,不需要真的開 port:

// tests/integration/api/user.test.ts
import request from 'supertest';
import app from '../../../src/app'; // ← 只 import app,不觸發 listen
 
describe('GET /api/v1/users', () => {
  it('returns user list', async () => {
    const res = await request(app).get('/api/v1/users');
    expect(res.status).toBe(200);
  });
});

如果 app.ts 自己呼叫 listen(),每次跑測試都會開 port,平行測試還會 port conflict。app.ts 只建 express 實例、bin/www.ts 負責真正的 HTTP 監聽,測試可以安全地只 import app


第二個選擇:layer-based 而不是 feature-based

Feature-based(按功能/模組分)

src/
├── users/
│   ├── user.controller.ts
│   ├── user.service.ts
│   ├── user.repository.ts
│   └── user.model.ts
├── posts/
│   ├── post.controller.ts
│   ├── post.service.ts
│   └── ...

Layer-based(按職責分)

src/app/
├── controllers/
│   ├── user.controller.ts
│   └── post.controller.ts
├── services/
│   ├── user.service.ts
│   └── post.service.ts
├── repositories/
│   ├── user.repository.ts
│   └── post.repository.ts

兩種都是主流,各有適用場景。Proto 選 layer-based,理由如下:

一、層的邊界比功能邊界更穩定。

功能會隨業務變化拆分、合併。User 功能可能被拆成 profile/account/;posts 和 comments 可能合併成 content/。但 controllers / services / repositories 這三層的邊界不會變——Controller 就是處理 HTTP,Service 就是商業邏輯,Repository 就是資料存取。

二、Base 類別的繼承更直觀。

Proto 有 BaseControllerBaseServiceBaseRepositoryBaseModel 四個基礎類別。layer-based 下,同層的 classes 繼承同一個 Base,結構一眼就清楚。如果是 feature-based,每個 feature 裡面有自己的 controller/service/repository,Base 類別放哪裡就變成問題。

三、跨功能依賴更容易看出來。

Service 可能需要多個 Repository(UserService 可能用 UserRepository 和 SessionRepository)。Layer-based 下,services/user.service.ts import repositories/user.repository.tsrepositories/session.repository.ts,依賴方向一目了然。Feature-based 則容易出現 feature 之間互相 import 的混亂。


第三個選擇:routes/ 為什麼在 app/ 外面

大多數教學把 router 和 controller 放在一起,或者把路由直接寫在 controller 裡。Proto 把它們分開:

  • src/routes/api/URL wiring,只負責「這個 URL 對應哪個 controller method」
  • src/app/controllers/HTTP 入出邏輯,負責 parse request、呼叫 service、回傳 response
// src/routes/api/users.ts — 只做 wiring
import { Router } from 'express';
import UserController from '../../app/controllers/user.controller';
import { authenticate } from '../../app/middlewares/auth/auth';
import { userValidators } from '../../app/validators/user.validator';
 
const router = Router();
 
router.get('/', authenticate, UserController.index);
router.post('/', userValidators.create, UserController.store);
router.get('/:id', authenticate, UserController.show);
 
export default router;
// src/app/controllers/user.controller.ts — 只做 HTTP 入出
class UserController extends BaseController {
  static async index(req: Request, res: Response) {
    const users = await UserService.getAll();
    this.sendSuccessResponse(res, users);
  }
}

路由層知道:URL、middleware 順序、validator 掛哪裡。Controller 不需要知道自己被哪個 URL 呼叫。

這個分離讓你可以:

  • 換 URL 不動 controller
  • 在 route 層看所有 middleware 的執行順序
  • 測試 controller 時直接 call static method,不需要 mock route

bootstrap/ 獨立一層的理由

src/bootstrap/ 不在 src/app/ 裡,它是獨立的一層。

這層的唯一責任是「按正確順序初始化各依賴」,它是整個系統的啟動編排者。這個設計細節和正確的 6 步驟順序在下一篇 Bootstrap 模式 會詳細拆開講,這裡先理解它的位置:

app.ts
  └── bootstrap/index.ts    ← 編排者
        ├── middleware.ts    ← step 1
        ├── routes.ts        ← step 2
        ├── errorHandlers.ts ← step 3
        ├── database.ts      ← step 4(await)
        ├── redis.ts         ← step 5(await)
        └── workers.ts       ← step 6

Bootstrap 需要 configs(讀設定)、需要 app/(掛 middleware / routes)。如果 bootstrap 放進 app/ 裡,就變成 app 本身在管自己的啟動,循環依賴的風險很高。獨立成 bootstrap/ 讓依賴方向清晰:bootstrap → app、configs,不反向。


這個結構的實際感受

學完這個結構之後,你在這個 codebase 裡找任何東西都很快:

  • 「這個 API endpoint 怎麼驗證 request?」→ validators/
  • 「送 email 的邏輯在哪?」→ notifications/
  • 「Redis cache 的 key 命名規則?」→ cache/
  • 「RBAC 怎麼檢查 permission?」→ rbac/

Feature-based 的優勢是「同一個功能的東西放一起」。Layer-based 的優勢是「知道某個職責在哪一層,就知道去哪找」。後者在有 Base 類別、跨功能依賴較多的專案裡更實用。


本系列文章

  • [[backend/framework/express/init|[express][01] Express + TypeScript 從零開始]]
  • [[backend/framework/express/eslint-typings|[express][02] ESLint + Typings 設定]]
  • [[backend/framework/express/jest-setup|[express][02-2] Jest 基礎設定]]
  • [[backend/framework/express/configs-layer|[express][M1] Config 層設計]]
  • [express][M1-2] 目錄結構設計(本篇)
  • [[backend/framework/express/bootstrap-pattern|[express][M2] Bootstrap 模式與啟動序列]]