A Thin BFF with Fastify
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.
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, serialisation, and type inference together — no need to drag in zod or hand-write interface mappings on top. For a BFF that doesn’t carry complex business logic, that’s a lot of noise removed. Express still requiring manual body-parser middleware in 2025 is a hard sell.
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' },
},
},
},
}
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 data = await fetch(`${process.env.USER_SVC}/internal/summary?userId=${userId}`, {
headers: { 'X-Internal-Auth': `${ts}.${sig}` },
}).then((r) => r.json())
return data
})
}
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.
Summary
- 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.
Sheng’s take, drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.