框架的邊界在哪裡
框架解決的是「請求進來、資料出去」這條路上的基礎設施。它不管:
- 你的 response 格式長什麼樣
- 金額要怎麼存才不會有精度問題
- 刪除的資料要真刪還是標記
- 同一個日期在不同時區的用戶看到的是什麼
- 業務層的錯誤要用什麼 code 代表
這些東西不難,但沒有明確決策的結果是:每個工程師各自處理,一個月後 codebase 裡有四種 response 格式、金額有的是 float 有的是整數、soft delete 有的忘了過濾——這些問題在 code review 很難抓,在 bug 出現時很難追。
Response Envelope
框架給你 res.json(data),但沒告訴你格式。沒定義的結果是:
// 工程師 A
res.json({ user: { id: 1, name: 'Alice' } });
// 工程師 B
res.json({ data: { id: 1, name: 'Alice' }, status: 'ok' });
// 工程師 C(錯誤時)
res.status(400).json({ message: 'Invalid email' });
res.status(400).json({ error: { code: 'INVALID_EMAIL', message: '...' } });前端對接三個不同結構的 API,是真實的痛。
決策點不複雜——選一個格式,全專案統一:
// 成功
{ "success": true, "data": { ... } }
// 失敗
{ "success": false, "error": { "code": "USER_NOT_FOUND", "message": "..." } }問題在於「要記得做」——這個決策不在任何框架的文件裡,沒有新人 onboarding 說明,就是會漂移。放進 Base Controller,所有 controller 繼承,格式就鎖住了。
Error Code 設計
HTTP status code 不夠用。400 Bad Request 可以是「email 格式錯」、也可以是「這個 username 已被使用」、也可以是「年齡低於限制」——前端要顯示不同的錯誤訊息,沒有 code 只能去 parse message 字串,非常脆。
業務層需要自己的 error code:
USER_NOT_FOUND
USER_EMAIL_ALREADY_EXISTS
INSUFFICIENT_BALANCE
ORDER_ALREADY_CANCELLED
RATE_LIMIT_EXCEEDED
FEATURE_NOT_AVAILABLE
命名慣例:RESOURCE_CONDITION,全大寫底線分隔。這個清單要有人維護(通常放 src/errors/codes.ts),不然又是每個工程師各自命名。
日期 / 時區一致性
最常見的錯誤:把 local time 存進資料庫。
// ❌ 存 local time:如果 server 在台灣,存的是 UTC+8
createdAt: new Date() // "2026-04-22T18:30:00.000+08:00"
// ✅ 統一 UTC
createdAt: new Date().toISOString() // "2026-04-22T10:30:00.000Z"決策:DB 永遠存 UTC,API response 永遠輸出 UTC(ISO 8601),顯示層負責轉換成 user timezone。
這個規則一旦有人沒遵守,跨時區的 query(「找出今天建立的訂單」)就會出現難以追查的 off-by-one 問題。
PostgreSQL 用 TIMESTAMPTZ(帶時區),不要用 TIMESTAMP(不帶時區)。
金額 / 貨幣處理
不要用 float 存金額。
// ❌ float 精度問題
0.1 + 0.2 === 0.30000000000000004
// ❌ 這會讓你的金流對帳對不起來
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);兩個選擇:
整數分(cents):金額存整數,100 代表 $1.00。加減乘除都是整數運算,精確。缺點是顯示時要除以 100,要記得處理四捨五入。
Decimal 型別:用 decimal.js 或 big.js,避免浮點誤差。DB 用 DECIMAL(15, 2) 而不是 FLOAT。
整數分適合多數場景;Decimal 適合需要高精度的計算(匯率換算、多步驟計算)。選一個,全專案統一。
Soft Delete
硬刪除(真的從 DB 移除)在很多場景是不對的——訂單關聯的用戶被刪了、稽核記錄找不到歷史資料、前端打 API 拿到 404 不知道是從來不存在還是被刪了。
Soft delete 用 deleted_at 欄位標記:
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;常被忽略的是:所有 query 都要過濾 deleted_at IS NULL。如果忘了,已刪除的資料就會出現在列表、搜尋結果、關聯查詢裡。
Sequelize 的 paranoid: true、Rails 的 acts_as_paranoid、Laravel 的 SoftDeletes 都有自動過濾——用框架的機制,不要手動加 WHERE deleted_at IS NULL 在每個 query 裡(那個一定有人會忘)。
如果你的框架沒有自動過濾(純 SQL 或輕量 ORM),放進 Repository 的 base query:
class BaseRepository<T> {
findAll(where = {}) {
return this.model.findAll({ where: { ...where, deletedAt: null } });
}
}ID / Slug 生成
Auto-increment integer ID 的問題:暴露資料量(用戶看到 user_id=1234 就知道你有幾個用戶)、沒有全域唯一性(merge DB 時衝突)。
三個選項:
UUID v4:完全隨機,沒有排序性。550e8400-e29b-41d4-a716-446655440000。DB index 效率較差(隨機插入 B-tree)。
ULID:時間前綴 + 隨機後綴,lexicographically sortable。01ARZ3NDEKTSV4RRFFQ69G5FAV。index 效率比 UUID 好,有時間資訊。
nanoid:短、URL-safe、自訂字符集。V1StGXR8_Z5jdHi6B-myT。適合用戶可見的短 ID(邀請碼、分享連結)。
決策要統一:主鍵用哪個、對外暴露的 ID 用哪個。不要主鍵 UUID 又有個 shortId 是 nanoid,用途沒區分清楚。
Input Sanitize(驗證之外)
Zod、Pydantic 做的是驗證(這個值合不合法)。Sanitize 是驗證之前的清理:
// 最容易漏的幾個
email: input.email.trim().toLowerCase() // 'Alice@Example.com ' → 'alice@example.com'
name: input.name.trim().replace(/\s+/g, ' ') // ' Alice Bob ' → 'Alice Bob'
// null byte injection(寫進 log 時會截斷)
input.name.replace(/\0/g, '')放在 validation 之前,讓 Zod/Pydantic 拿到的是已清理的資料。
Pagination Metadata
框架給你 .findAll({ limit, offset }),不給你把結果包成前端需要的格式。
哪些欄位要包、pagination 的 URL 要不要附在 response 裡、cursor 怎麼編碼——每個框架子系列都有自己的決策,但每個 list API 都要決定,不要等到有人問「為什麼這個 API 沒有 total?」。
概念設計在 Pagination 設計;各框架的實作在對應子系列。
這些東西的共同點:不難、但需要主動決策、決策要寫在某個地方。放進架構文件、放進 ADR、放進 onboarding checklist——選一個,讓新進工程師第一天就知道這個專案的慣例是什麼,不是靠 code review 來糾正。
