用 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 翻新計劃,新漆未乾。