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 有 BaseController、BaseService、BaseRepository、BaseModel 四個基礎類別。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.ts 和 repositories/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 模式與啟動序列]]
