框架的邊界在哪裡

框架解決的是「請求進來、資料出去」這條路上的基礎設施。它不管:

  • 你的 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.jsbig.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 來糾正。


延伸閱讀