Backend Fastify Stack (Bun)
High-performance backend stack, run on Bun:
- Bun - Runtime, package manager, test runner, and watch-mode dev server (no Node.js, npm, or tsx)
- Fastify 5.x - Web framework
- TypeScript - Type safety, executed directly by Bun (no build step for services)
- pino-pretty - Pretty logging for development
- @fastify/env - Environment variable validation with JSON Schema
- @fastify/cors - CORS support
- fastify-plugin - Plugin system
Bun runs TypeScript source directly, so there is no compile step in the dev loop and
none in production for a service — you ship and run the source. tsc is kept only as a
type checker (tsc --noEmit), never as a build tool.
Environment Setup
Use asdf to manage Bun:
# Install the Bun plugin (one-time)
asdf plugin add bun
# Pin Bun for the project (writes .tool-versions)
asdf set bun latest
asdf install
This writes a .tool-versions file pinning Bun so the whole team runs the same version.
A born-on-Bun service needs only bun in .tool-versions — add nodejs only if the
repo also ships an npm-distributed CLI that needs the Node toolchain.
Reference Files
| File | When to Use |
|---|---|
| setup-guide.md | Starting a new backend project from scratch |
| patterns.md | Implementing routes, services, schema validation |
| authentication.md | Auth: deny-by-default gateway, sessions, tokens, OIDC, local dev mode |
| api-design.md | Swagger/OpenAPI integration, response format, errors |
| mcp-integration.md | Integrating MCP server for AI agent access |
| gotchas.md | Debugging issues, common mistakes and fixes |
Quick Reference
Commands
# Dev server with watch mode (Bun reloads on change)
bun run dev # → bun --watch src/index.ts
# Type check (no emit — Bun runs the source, tsc only checks types)
bun run typecheck # → tsc --noEmit
# Run the server (no build needed — Bun executes the source)
bun run start # → bun run src/index.ts
# Tests (Bun's built-in runner)
bun test
Package Management
Use Bun for all dependency management — never edit package.json by hand:
bun add fastify-plugin @fastify/cors # runtime deps
bun add -d @types/bun pino-pretty # dev deps
bun remove <pkg> # remove
bun add resolves the latest compatible version and keeps bun.lock in sync. Commit
bun.lock.
Key Imports
// Fastify types
import Fastify, { FastifyInstance, FastifyPluginAsync } from 'fastify'
import fp from 'fastify-plugin'
// Common plugins
import fastifyEnv from '@fastify/env'
import cors from '@fastify/cors'
Configuration Access
Always access configuration through fastify.config, never process.env directly:
// CORRECT - type-safe, validated at startup
const port = fastify.config.PORT
const apiKey = fastify.config.API_KEY
// WRONG - no validation, no type safety
const port = process.env.PORT // Don't do this
@fastify/env's JSON Schema covers per-variable shape validation. When config needs
cross-field contracts ("exactly one of these two modes"), environment-detection guards,
or strict tri-state booleans, hand-rolled fail-fast validation at boot is the right tool
— see setup-guide.md "When JSON Schema isn't enough".
Response Format
Pick one convention per API and stick to it. The envelope below is one option; structured
domain-specific bodies (e.g. {error: "forbidden", required, environment, your_role}) are
equally valid and often spec-mandated — see api-design.md.
// The envelope option
{
success: boolean
data?: T
error?: string
metadata?: { timestamp: Date }
}
Plugin Pattern
import fp from 'fastify-plugin'
export default fp(async (fastify, opts) => {
// Plugin logic here
fastify.decorate('something', value)
}, '5.x')
Route Pattern
import { FastifyPluginAsync } from 'fastify'
const routes: FastifyPluginAsync = async (fastify, opts) => {
fastify.get('/', async (request, reply) => {
return { success: true, data: 'example' }
})
}
export default routes
Service Pattern
// 1. Create service class
export class MyService {
constructor(private fastify: FastifyInstance) {}
async doWork() {
// Access config through fastify instance
const apiKey = this.fastify.config.API_KEY
this.fastify.log.info('Service method called')
}
}
// 2. Declare module augmentation
declare module 'fastify' {
interface FastifyInstance {
myService: MyService
}
}
// 3. Initialize and decorate in app.ts
fastify.decorate('myService', new MyService(fastify))
TypeScript Config
Bun runs the source directly, so tsconfig.json is for type checking only
("noEmit": true). The Bun-native config:
{
"compilerOptions": {
"target": "ESNext",
"module": "Preserve", // let Bun own module resolution
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"types": ["@types/bun"], // Bun globals (Bun.file, Bun.serve, etc.)
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"noEmit": true
}
}
In a Bun-workspaces monorepo, the package tsconfigs use TypeScript project references
("composite": true, per-package references) and the root runs tsc -b; the runtime
story is unchanged — Bun still executes the source.
Project Structure
backend/
├── src/
│ ├── plugins/ # Fastify plugins (env, auth, etc.)
│ ├── routes/ # HTTP route handlers
│ ├── services/ # Business logic classes
│ ├── lib/ # Shared utilities / clients
│ ├── app.ts # Plugin registration & setup
│ └── index.ts # Server entry point
├── package.json
├── tsconfig.json
├── .env.example
└── .gitignore
Common Gotchas
- Plugin order matters: Register env plugin first, then services, then routes
- Config access: Use
fastify.config.VARnotprocess.env.VAR - Server ready: Call
await server.ready()before accessing config in index.ts - No build step: Bun runs the source;
tscis--noEmitfor type checking only - Path normalization: Centralize path utilities, handle root '/' as special case
- Package management: Use
bun add <pkg>not manualpackage.jsonedits
CI & Code Quality
Wire lint, format, and type-check into CI from the start. The cross-cutting setup (asdf
provisioning + caching, path-filtered workflows, lockfile-frozen installs, the GitHub Actions
templates) lives in the ci-quality-gates skill; this section is the Fastify-stack
specifics that plug into it.
Linter + formatter: oxlint + oxfmt (the Jarvus standard — not eslint/prettier):
bun add -d oxlint oxfmt
The script contract. Expose the same four scripts every Jarvus TS package does, so CI
just calls bun run <name>:
{
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun run src/index.ts",
"typecheck": "tsc --noEmit",
"lint": "oxlint index.ts src",
"format": "oxfmt index.ts src",
"format:check": "oxfmt --check index.ts src",
"test": "bun test"
}
}
Use the base oxc config (references/templates/oxlintrc.base.json from ci-quality-gates,
correctness=error) as the service's .oxlintrc.json — backend code doesn't need the React
tier. In a Bun-workspaces monorepo, put the base config at the repo root as
.oxlintrc.base.json and have each package extend it; a service that contains a UI subfolder
adds "ignorePatterns": ["ui"] so the UI is linted by its own config.
CI workflow. A single service uses one ts-lint-style job (lint + format:check +
typecheck) plus a test job; a monorepo uses the per-package matrix in ci-quality-gates's
lint.yml / test.yml. All provisioned by the shared setup-asdf composite. Type-check is a
required gate — tsc --noEmit catches bugs no linter does.