A Thin BFF with Fastify
A BFF sounds like a grand architectural decision, but in practice it is just a translation layer. The frontend wants data in a certain shape; the BFF reshapes what comes back from downstream services and shields the frontend from everything it doesn’t need to know. Keep it thin — a thick BFF is just a copy of your backend complexity with a different address.
Correction (2 November 2026, 18:45 Taipei): the example now builds the downstream URL with URL and searchParams.set(), and returns 502 with an error schema when the downstream call is not 2xx; type inference is noted as needing a type provider; the Express remark now reflects the built-in express.json(). Suggested by Codex (gpt-6-astra) (example and type inference) and Claude (Express), checked by Claude.
Why Fastify
Fastify v5 shipped in September 2024, and since then I haven’t had much reason to reach for anything else in the Node.js HTTP space. The pitch is clear: schema-driven request validation and response serialisation, a plugin system with proper scope isolation, and performance that actually holds up under load.
The type support hits the right level. You write one JSON Schema definition and get validation and serialisation together, with no need to drag in zod. TypeScript inference needs a type provider on top (TypeBox or json-schema-to-ts, for example); the example below doesn’t use one, so it asserts the type of req.query by hand. For a BFF that doesn’t carry complex business logic, that’s a lot of noise removed. Express has shipped express.json() since 4.16, but you still wire it up yourself and handle validation and typing separately, which is a hard sell in 2025.
Holding the boundary
The BFF sits between the frontend and downstream services. Upstream requests get no trust — schema validation happens here. Downstream calls are protected with HMAC signatures on an X-Internal-Auth header. No external PKI needed; a shared secret across internal services is sufficient.
Error handling and logging live in this layer. Do not proxy downstream errors directly to the frontend. Wrap them here, attach a request ID, and write the log entry so the next person debugging has something to 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
The schema does two things at once: malformed query params get a 400 before the handler runs, and the response is serialised and filtered against the schema before it leaves — no manual pick or omit needed. That’s the part of Fastify I find genuinely low-friction.
Keeping the layer thin
Thin does not mean doing nothing. It means doing only what belongs here: injecting auth context, signing downstream calls, validating shapes in and out, wrapping errors, writing logs. The moment you find yourself writing “if service A returns X, call service B” logic inside the BFF, you have drifted into orchestration territory. At that point, the layer boundary needs rethinking — not more flags or middleware.
Notes
- Fastify v5 unifies validation and serialisation under one schema definition; no extra validation library needed in a BFF.
- HMAC signatures on an internal header are lightweight protection for downstream calls without requiring external PKI.
- Error handling and logging belong in the BFF layer; wrap errors here, not in the frontend or downstream services.
- Once orchestration logic creeps in, the BFF boundary has shifted; that’s worth catching early rather than patching with more middleware.
Ideas and technical judgement by Sheng; drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.