Tutorial: Request scope with Fastify
This tutorial shows how to wire DiCaf's request scope into a Fastify application.
By the end you will have a container that creates a fresh RequestContext per HTTP
request and makes it available to any service that needs it.
Prerequisites: Node.js ≥ 20, TypeScript 5+, a Fastify project.
1. Install
npm install @caffeine-projects/dicaf fastify
2. TypeScript config
DiCaf uses TC39 stage 3 decorators — no reflect-metadata needed.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"experimentalDecorators": false,
"strict": true
}
}
3. Create a request-scoped service
Scopes.REQUEST tells DiCaf to create a new instance for each request and discard it
when the request ends.
// src/request.context.ts
import { Injectable, Lifetime, PostConstruct } from '@caffeine-projects/dicaf/decorators'
import { Scopes } from '@caffeine-projects/dicaf'
@Injectable()
@Lifetime(Scopes.REQUEST)
export class RequestContext {
readonly correlationId = crypto.randomUUID()
@PostConstruct()
onCreated(): void {
console.log(`[${this.correlationId}] request started`)
}
}
4. Wire the container into Fastify
The onRequest hook calls requestScopeManager.run(). Everything inside that
callback runs within a single request's async context — DiCaf uses it to isolate
request-scoped instances between concurrent requests.
// src/app.ts
import fastify from 'fastify'
import type { Container } from '@caffeine-projects/dicaf'
export async function buildServer(container: Container) {
const server = fastify({ logger: true })
server
.addHook('onRequest', (_req, _reply, done) => {
container.requestScopeManager.run(() => done())
})
.addHook('onClose', async () => {
await container.dispose()
})
return server
}
onClose disposes the container when Fastify shuts down, running any @PreDestroy
hooks on singleton services.
5. Inject RequestContext into a singleton
Singleton services cannot directly receive a request-scoped dependency — their
instance is created once, before any request arrives. Use provide() to get a
Provider<T> instead: a thin wrapper that resolves the current request's instance
on each call.
// src/cats.service.ts
import { provide, type Provider } from '@caffeine-projects/dicaf'
import { Injectable } from '@caffeine-projects/dicaf/decorators'
import { RequestContext } from './request.context.js'
@Injectable([provide(RequestContext)])
export class CatsService {
constructor(private readonly ctx: Provider<RequestContext>) {}
findAll(): string[] {
console.log(`[${this.ctx.get().correlationId}] findAll`)
return ['Whiskers', 'Shadow']
}
}
this.ctx.get() is called at request time, not at construction time, so it always
returns the instance that belongs to the current request.
6. Register routes and start
// src/index.ts
import { DiCaf } from '@caffeine-projects/dicaf'
import { buildServer } from './app.js'
import { CatsService } from './cats.service.js'
const container = new DiCaf()
await container.init()
const server = await buildServer(container)
server.get('/cats', async () => {
return container.get(CatsService).findAll()
})
await server.listen({ port: 3000, host: '0.0.0.0' })
How it works
HTTP request arrives
│
├─ onRequest hook → requestScopeManager.run()
│ Creates an isolated async context for this request
│
├─ route handler runs
│ container.get(CatsService) → singleton (reused)
│ CatsService.ctx.get() → RequestContext for this request
│
└─ response sent
Request scope is discarded — RequestContext is garbage collected
Each concurrent request gets its own RequestContext. They never share state.
What's next
- Add more request-scoped services the same way — annotate with
@Lifetime(Scopes.REQUEST)and inject viaprovide(). - Use
scan()to auto-import decorated files instead of listing them manually. See the Scanning Files guide. - See the Mixing Scopes guide for the full explanation of why
provide()is required. - A complete working example with PostgreSQL, Redis, and health checks is available in the
examples/05-fastify-cats-completedirectory.