Fastify
Fastify has plugins and decorators: fastify.decorate('db', db) makes the database reachable from handlers. To type it, you add declare module 'fastify' with db on every Fastify instance, whether the plugin that decorates it was registered or not. With injecute:
- Typed from the registrations. No declaration merging: a service's type comes from its factory, and a module that needs a missing service is a compile error.
- Plugins for HTTP, the container for services. Keep Fastify plugins for what is about HTTP (auth hooks, CORS, rate limits, schemas). Repositories, clients and domain services live in the container, so a queue worker, a script or a test uses them without starting Fastify.
- Modules bring their own routes. Each feature module contributes a route plugin with its prefix; the HTTP module doesn't import the features.
- Shutdown in the right order.
server.close()runs first, waits for open requests and runs youronClosehooks; then the database pool closes. - Tests with
inject(). An isolated fork replaces the database for the whole graph, andserver.inject()sends requests without a port.
The HTTP module
The HTTP module declares an extension point for route plugins, and starts the server in a lifecycle hook:
// http.ts
import Fastify, { type FastifyPluginAsync } from 'fastify';
import { collect, createTag, lifecycle, type ServiceRegistry } from 'injecute';
import { storage } from './context.ts';
/** A Fastify plugin a module registers under a prefix. */
export interface Routes {
readonly prefix: string;
readonly plugin: FastifyPluginAsync;
}
/** The extension point: every module's routes. */
export const routes = createTag('routes').of<Routes>();
export const addHttp = (c: ServiceRegistry<{ port: number }>) =>
c
// a factory, not the instance: a Fastify instance is thenable
.addSingleton(
'createServer',
(contributions) => () => {
const server = Fastify({ logger: true });
// preValidation runs after the body is parsed: the handlers run in this context
server.addHook('preValidation', (request, _reply, done) => {
storage.run(
{
traceId: request.id,
tenantId: request.headers['x-tenant-id'] as string | undefined,
},
done,
);
});
for (const { prefix, plugin } of contributions)
server.register(plugin, { prefix });
return server;
},
[collect(routes)],
)
.addSingleton(
lifecycle.start('server'),
async (createServer, port) => {
const server = createServer();
await server.listen({ port, host: '0.0.0.0' });
return () => server.close(); // stops accepting requests, waits for open ones, runs onClose hooks
},
['createServer', 'port'],
);Register a factory, not the Fastify instance
A Fastify instance is thenable: await server waits for ready(). A factory that returns it is treated like an async factory, so get() would return a promise instead of the server. Register a function that creates the server, as above; tests call it too.
The server is a hook, so running.stop() closes it before dispose() releases the connections.
A feature module
A module registers its services and contributes a route plugin. The handlers close over orders:
// orders.ts
import type { ServiceRegistry } from 'injecute';
import { routes, type Routes } from './http.ts';
export const addOrders = (c: ServiceRegistry<{ db: Database }>) =>
c.addSingleton('orders', OrderService, ['db']).addSingleton(
routes('orders'),
(orders): Routes => ({
prefix: '/orders',
plugin: async (server) => {
server.get('/', () => orders.list());
server.post<{ Body: { item: string } }>(
'/',
{
schema: {
body: {
type: 'object',
required: ['item'],
properties: { item: { type: 'string' } },
},
},
},
async (request, reply) =>
reply.code(201).send(await orders.place(request.body.item)),
);
},
}),
['orders'],
);Each route plugin is encapsulated by Fastify as usual: hooks and decorators it adds stay in its prefix.
The composition root
// app.ts
import { DIContainer } from 'injecute';
import { storage } from './context.ts'; // export const storage = new AsyncLocalStorage<RequestContext>()
import { addHttp } from './http.ts';
import { addOrders } from './orders.ts';
export const createApp = () =>
new DIContainer()
.addInstance('config', loadConfig())
.addInstance('context', { current: () => storage.getStore() })
.addSingleton('port', (config) => config.port, ['config'])
.addSingleton('db', (config) => new Database(config.databaseUrl), {
dependencies: ['config'],
dispose: (db) => db.close(),
})
.extend(addHttp)
.namespace('Orders', addOrders)
.seal();// main.ts
import { startLifecycle } from 'injecute';
import { createApp } from './app.ts';
const running = await startLifecycle(createApp());
process.once('SIGTERM', () =>
running.stop().catch((error) => console.error(error)),
);For a complete entry point with a grace period and a second signal, see Graceful shutdown in Node.js. Don't call dispose() in an onClose hook: the lifecycle closes the server first and disposes after it.
Request context
The HTTP module starts the request context in a preValidation hook.
Start the context after the body is parsed
For requests with a body, Fastify runs the hooks after parsing from the request stream's end event, in a different async context, so a context started in onRequest can be lost by the handler. Start it in preValidation, or use @fastify/request-context, which re-enters its context there, and read it in the accessor: { current: () => requestContext.get('context') }.
Fastify's request.log carries the request id, but only where you pass the request. A logger in the container that reads the context works in every service; see Logging. To use one pino instance for both, register it in the container and give it to Fastify: Fastify({ loggerInstance: logger }).
Tests
Build the app, replace the database in an isolated fork, create a server from the fork and send it requests with inject():
// orders.test.ts
import { expect, it } from 'vitest';
import { createApp } from './app.ts';
it('places an order', async () => {
await using test = createApp()
.fork({ isolated: true })
.addInstance('db', new FakeDatabase(), { replace: true });
const server = test.get('createServer')();
const response = await server.inject({
method: 'POST',
url: '/orders',
payload: { item: 'book' },
});
expect(response.statusCode).toBe(201);
await server.close();
});The test doesn't start the lifecycle, so nothing listens. The fork's route plugins are built with the fake database, createApp()'s are untouched, and await using disposes what the test created. See Testing.
Plugins or services?
| Put it in | When |
|---|---|
| A Fastify plugin | It is about HTTP: authentication hooks, CORS, rate limits, content parsers, shared schemas |
| The container | The app uses it: repositories, clients, domain services, the logger, configuration |
| A route plugin by tag | A feature module's routes, built with the module's services |