用 Fastify 寫一層薄 BFF
BFF 這個詞聽起來很架構、很正式,但它本質上就是一層轉譯:前端要什麼結構的資料,BFF 就負責把下游打回來的東西整形成那個結構,同時擋住前端不需要知道的東西。這層不用厚,厚了只是把後端的複雜度複製一份搬過來。
更正(2026-11-02 18:45):範例改用 URL 與 searchParams.set() 組下游網址,下游回非 2xx 時改為回傳 502 與錯誤 schema;型別推斷註明要另接 Type Provider;Express 的說法改為已內建 express.json()。修正建議:Codex(gpt-6-astra)(範例與型別推斷)、Claude(Express),Claude 驗證。
為什麼是 Fastify
Fastify v5 在 2024 年 9 月正式發布,從那之後我就沒太多理由再看其他 Node.js HTTP 框架了。核心優勢很直接:schema 驅動的 request 驗證與 response 序列化,plugin 系統有完整的作用域隔離(scope isolation),效能在 Node.js 圈子裡原本就拿得出手。
最重要的是型別支援的尺度剛好。它整合 JSON Schema,你寫一份 schema,驗證和序列化同時都有,不用另外拉 zod。TypeScript 的型別推斷要另外接 Type Provider(例如 TypeBox 或 json-schema-to-ts),下面的範例沒接,所以 req.query 用手寫的型別斷言。對 BFF 這種不需要複雜業務邏輯的層來說,能省不少噪音。Express 從 4.16 起內建 express.json(),但還是要自己掛上,驗證和型別也得另外處理,2025 年已經很難說服人繼續用。
BFF 的邊界怎麼守
這層夾在前端和下游服務之間,對前端它是「後端」,對下游它是「前端」。上游進來的 request 一律不信任,schema 驗證在這裡做。下游的 call 透過 HMAC 保護,HTTP header 帶 X-Internal-Auth,不需要外部 PKI,內部服務共享同一把 secret 就夠。
錯誤處理和 log 都集中在這層。下游回傳的 error 不要直接丟給前端,在這裡統一包裝、log 好 request ID,trace 才接得起來。
// routes/summary.ts
import type { FastifyPluginAsync } from 'fastify'
import { createHmac } from 'node:crypto'
const schema = {
querystring: {
type: 'object',
required: ['userId'],
properties: { userId: { type: 'string', minLength: 1 } },
},
response: {
200: {
type: 'object',
properties: {
userId: { type: 'string' },
balance: { type: 'number' },
},
},
502: {
type: 'object',
properties: {
error: { type: 'string' },
requestId: { type: 'string' },
},
},
},
}
const summaryRoute: FastifyPluginAsync = async (fastify) => {
fastify.get('/summary', { schema }, async (req, reply) => {
const { userId } = req.query as { userId: string }
const ts = Date.now().toString()
const sig = createHmac('sha256', process.env.INTERNAL_SECRET!)
.update(`${ts}:${userId}`)
.digest('hex')
const url = new URL(`${process.env.USER_SVC}/internal/summary`)
url.searchParams.set('userId', userId)
const res = await fetch(url, {
headers: { 'X-Internal-Auth': `${ts}.${sig}` },
})
if (!res.ok) {
req.log.error({ status: res.status }, 'user service failed')
return reply.code(502).send({ error: 'upstream_failed', requestId: req.id })
}
return res.json()
})
}
export default summaryRoute
Schema 在這裡同時做兩件事:進來的 query 不合格直接 400,出去的 response 按 schema 序列化,省力地把不需要暴露給前端的欄位過濾掉。這是 Fastify 最省腦的地方,不用手寫 pick/omit。
厚度控制
BFF 越薄越好,但「薄」不是不做事,是只做這層該做的事:auth context 注入、下游 call 的 HMAC 簽章、request/response 的 schema 驗證、error 包裝與 log。業務邏輯不屬於這層,一旦開始在 BFF 裡寫「如果 A 服務回 X 就去問 B 服務」這種 orchestration,就是往 API gateway 走了,責任邊界要重新想清楚。
補充筆記
- Fastify v5 的 schema 驅動讓驗證與序列化合而為一,BFF 層不需要額外的 validation library。
- 下游呼叫用 HMAC 簽章保護,輕量且不依賴外部 PKI,內部服務共享 secret 即可。
- 錯誤處理和 log 集中在 BFF 層,下游 error 在這裡統一包裝,不直接丟給前端。
- BFF 的邊界是 schema 驗證、認證 context 注入、下游轉譯,業務 orchestration 一旦出現就該重新評估分層。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 列入 20260613 blog 翻新計劃,新漆未乾。