Migrating from Fastify
This guide helps you migrate from Fastify to Setu-TS. Setu-TS shares Fastify’s philosophy of plugin encapsulation and hooks, while adding dependency injection, TypeScript-first design, and runtime independence.
Key Differences
| Concept | Fastify | Setu-TS |
|---|---|---|
| Runtime | Node.js only | Deno, Node.js, Bun, Cloudflare Workers |
| Request/Response | FastifyRequest/FastifyReply | ctx.request / ctx.response |
| Plugin System | fastify.register() | app.register(plugin) |
| Encapsulation | Per-instance decoration | None: services are application-wide |
| Decorators | fastify.decorate() | Service registry (capability tokens) |
| Schema | JSON Schema (ajv) | Zod (or custom validators) |
Basic Application
Fastify
import fastify from 'fastify';
const app = fastify({ logger: true });
app.get('/', async (request, reply) => {
return { message: 'Hello' };
});
app.listen({ port: 3000 }, (err) => {
if (err) throw err;
console.log('Server listening on port 3000');
});
Setu-TS
import type { MiddlewareFunction } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { LoggerPlugin } from '@setu-ts/logger-plugin';
const app = createApplication();
app.register(RuntimePlugin());
app.register(LoggerPlugin());
app.router.get('/', async (ctx) => {
return ctx.response.json({ message: 'Hello' });
});
await app.start({ port: 3000 });
console.log('Server listening on port 3000');
Routes
Fastify
interface Params {
id: string;
}
interface Querystring {
search?: string;
}
interface Body {
name: string;
email: string;
}
app.get<{ Params; Querystring }>('/:id', async (request, reply) => {
const { id } = request.params;
const { search } = request.query;
return { id, search };
});
app.post<{ Body }>('/users', async (request, reply) => {
const body = request.body;
return { created: body.name };
});
Setu-TS
app.router.get('/users/:id', async (ctx) => {
const id = ctx.params.id;
const search = ctx.query.search;
return ctx.response.json({ id, search });
});
app.router.post('/users', async (ctx) => {
const body: Record<string, unknown> = await ctx.request.json();
return ctx.response.status(201).json({ created: body });
});
Route Groups
Fastify
app.register(
async (fastify) => {
fastify.get('/users', () => []);
fastify.post('/users', () => ({}));
},
{ prefix: '/api' },
);
Setu-TS
app.router.get('/api/users', async (ctx) => ctx.response.json([]));
app.router.post('/api/users', async (ctx) => ctx.response.json({}));
// Or use a route group
app.router.group('/api', (group) => {
group.get('/users', async (ctx) => ctx.response.json([]));
group.post('/users', async (ctx) => ctx.response.json({}));
});
Hooks
Fastify
// onRequest
app.addHook('onRequest', (request, reply, done) => {
console.log('onRequest');
done();
});
// preHandler
app.addHook('preHandler', (request, reply, done) => {
console.log('preHandler');
done();
});
// preSerialization
app.addHook('preSerialization', (request, reply, payload, done) => {
console.log('preSerialization');
done();
});
// onResponse
app.addHook('onResponse', (request, reply, done) => {
console.log('onResponse');
done();
});
// onSend
app.addHook('onSend', (request, reply, payload, done) => {
console.log('onSend');
done();
});
// onError
app.addHook('onError', (request, reply, error, done) => {
console.log('onError');
done();
});
Setu-TS
Lifecycle hooks are registered from inside a plugin, through the ctx its register(ctx) receives;
the application object has no lifecycle member. Middleware is the alternative that needs no
plugin.
// Inside a plugin's register(ctx)
ctx.lifecycle.onRequest((ctx) => {
console.log('Request started:', ctx.request.url);
});
ctx.lifecycle.onResponse((ctx) => {
console.log('Response sent:', ctx.response.snapshot().status);
});
ctx.lifecycle.onError((error, ctx) => {
console.error('Request error:', error);
});
// Using middleware for transformation
const preSerializationMiddleware: MiddlewareFunction = async (ctx, next) => {
await next();
// Post-processing after response is generated
};
app.middleware.add(preSerializationMiddleware);
Middleware
Fastify
app.use((req, res, next) => {
console.log('Middleware');
next();
});
// Route-specific
app.use('/api/*', apiMiddleware);
Setu-TS
const myMiddleware: MiddlewareFunction = async (ctx, next) => {
console.log('Middleware');
await next();
};
app.middleware.add(myMiddleware);
// Route-specific middleware
app.router.get('/api/users', {
middleware: [myMiddleware],
handler: async (ctx) => ctx.response.json([]),
});
Decorators
Fastify
// Register decorator
app.decorate('myUtil', {
formatDate: (date: Date) => date.toISOString(),
});
// Use decorator
app.get('/', async (request, reply) => {
return { date: app.myUtil.formatDate(new Date()) };
});
Setu-TS
import { createCapabilityToken, type IPlugin } from '@setu-ts/common';
interface MyUtil {
formatDate(date: Date): string;
}
const MY_UTIL = createCapabilityToken('my-util');
export function UtilitiesPlugin(): IPlugin {
return {
name: 'utilities',
version: '1.0.0',
register(ctx) {
ctx.services.register<MyUtil>(MY_UTIL, {
formatDate: (date: Date) => date.toISOString(),
});
ctx.router.get('/', async (requestCtx) => {
const myUtil = requestCtx.services.get<MyUtil>(MY_UTIL);
return requestCtx.response.json({ date: myUtil.formatDate(new Date()) });
});
},
};
}
app.register(UtilitiesPlugin());
Validation
Fastify
app.addSchema({
$id: 'userSchema',
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
});
app.post(
'/users',
{
schema: {
body: 'userSchema',
},
},
async (request, reply) => {
return { created: true };
},
);
Setu-TS
import { z } from 'zod';
import { validatedStateKey } from '@setu-ts/common';
import { validateBody, ValidationPlugin } from '@setu-ts/validation-plugin';
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
});
app.register(ValidationPlugin({ errorFormat: 'rfc9457' }));
app.router.post('/users', {
middleware: [validateBody(userSchema)],
handler: async (ctx) => {
// The parsed value, with the schema's defaults and transforms applied.
const body = ctx.state.get(validatedStateKey('body'));
return ctx.response.status(201).json({ created: body });
},
});
validateBody(...) answers an invalid body with 400 before the handler runs, like a Fastify
schema.body. It also describes the body in the OpenAPI document, so the route needs no separate
schema.body for documentation. A route’s schema option documents but does not validate. To
validate by hand instead, call userSchema.safeParse(await ctx.request.json()) in the handler.
Error Handling
Fastify
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
error: error.message,
});
});
Setu-TS
import { errorHandler } from '@setu-ts/exceptions';
app.middleware.add(
errorHandler({
respond(error, ctx) {
return ctx.response.status(error.statusCode).json({ error: error.message });
},
}),
{ priority: 0, name: 'error-handler' },
);
errorHandler logs the error and normalizes it to an HttpError before respond sees it, so an
unexpected error arrives as a 500 whose message is generic while the original goes to the log.
Omit respond to use the configured format ('rfc9457' Problem Details is what setu new
scaffolds).
Type Providers
Fastify
interface FastifySchema {
body: { name: string; email: string };
}
app.post<FastifySchema>('/users', async (request, reply) => {
const body = request.body; // Typed
return { created: true };
});
Setu-TS
interface CreateUserData {
name: string;
email: string;
}
app.router.post('/users', async (ctx) => {
const body = await ctx.request.json<CreateUserData>();
return ctx.response.status(201).json({ created: true });
});
Lifecycle Hooks
Fastify
// onReady
app.addHook('onReady', async () => {
console.log('Ready!');
});
// onClose
app.addHook('onClose', async () => {
console.log('Closing');
});
Setu-TS
ctx.lifecycle.onBootstrap(() => {
console.log('Ready!');
});
ctx.lifecycle.onClose(() => {
console.log('Closing');
});
// More detailed shutdown lifecycle
ctx.lifecycle.onStopping(() => {
console.log('Stopping - still serving; deregister from discovery here');
});
ctx.lifecycle.onShutdown(() => {
console.log('Shutdown - no longer serving; close connections here');
});
Plugins
Fastify
async function authPlugin(fastify, options) {
fastify.decorate('authenticate', async (request) => {
if (!request.headers.authorization) {
throw new Error('Missing authorization header');
}
});
}
app.register(authPlugin, {/* options */});
Setu-TS
// Self-contained example plugin demonstrating the registration pattern.
function MyAuthPlugin(): IPlugin {
return {
name: 'my-auth',
version: '1.0.0',
async register(ctx) {
ctx.services.register('authenticate', async (request: Request) => {
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
throw new Error('Missing authorization header');
}
});
},
};
}
app.register(MyAuthPlugin());
For authentication itself, @setu-ts/auth-plugin already does this. AuthPlugin verifies the token
on every request and sets ctx.request.user, and requireAuth() in a route’s middleware answers
401.
Server Decoration
Fastify
app.decorateRequest('user', null);
app.addHook('onRequest', async (request, reply) => {
request.user = { id: 1, name: 'John' };
});
Setu-TS
const loadAccount: MiddlewareFunction = async (ctx, next) => {
// Per-request data goes in ctx.state, under a key your application owns.
ctx.state.set('app:account', { id: 1, name: 'John' });
await next();
};
app.middleware.add(loadAccount);
The authenticated principal has its own place: ctx.request.user, which AuthPlugin sets. It
accepts one write per request, so a later stage cannot silently replace the identity an earlier one
established.
Async Initialization
Fastify
const app = fastify();
await app.ready();
// App is ready but not listening
Setu-TS
const app = createApplication();
app.register(RuntimePlugin());
await app.start();
// App is ready and listening (if port specified)
// Or ready for fetch (if no port)
Testing
Fastify
import Fastify from 'fastify';
const app = Fastify();
app.get('/', async () => ({ hello: 'world' }));
const response = await app.inject({
method: 'GET',
url: '/',
});
console.log(response.json());
Setu-TS
import { RuntimePlugin } from '@setu-ts/runtime';
import { createTestApp } from '@setu-ts/testing';
const app = await createTestApp({ plugins: [RuntimePlugin()] });
app.router.get('/', async (ctx) => ctx.response.json({ hello: 'world' }));
const response = await app.inject({
method: 'GET',
url: '/',
});
console.log(response.json());
await app.stop();
app.inject() works like Fastify’s and needs no socket. createTestApp starts the application
first, and it needs a runtime plugin among plugins. To test your real composition, pass the
factory your main.ts uses instead: createTestApp({ app: createApp() }).
Common Patterns
Encapsulation
Fastify
app.register(async (child) => {
child.decorate('childUtil', () => 'child');
child.get('/child', () => ({ util: child.childUtil() }));
});
// parent cannot access childUtil
Setu-TS
There is no Fastify-style encapsulation: a service registered by any plugin is visible to the whole application. Keep a helper private by not registering it, and closing over it in the plugin instead:
import type { IPlugin } from '@setu-ts/common';
export function ChildPlugin(): IPlugin {
const childUtil = () => 'child'; // visible only inside this plugin
return {
name: 'child',
version: '1.0.0',
register(ctx) {
ctx.router.get('/child', (requestCtx) => requestCtx.response.json({ util: childUtil() }));
},
};
}
Reply Decorators
Fastify
app.decorateReply('withUser', function (user) {
this.user = user;
return this;
});
app.get('/', async (request, reply) => {
return reply.withUser({ id: 1 }).code(200);
});
Setu-TS
import type { HandlerResult, IRequestContext } from '@setu-ts/common';
// A plain function in place of a reply decorator
function withUser(ctx: IRequestContext, user: { id: number }): HandlerResult {
return ctx.response.status(200).json({ user });
}
app.router.get('/', async (ctx) => withUser(ctx, { id: 1 }));
Migration Checklist
- Replace
fastify()withcreateApplication() - Replace
app.get/post/put/deletewith programmatic routes - Replace hooks with lifecycle hooks or middleware
- Replace decorators with service registration
- Replace JSON Schema validation with a Zod schema in
validateBody(...) - Keep
app.inject()in tests, and boot the real app withcreateTestApp({ app: createApp() }) - Update logging to use
LoggerPlugin - Replace
setErrorHandlerwitherrorHandler()from@setu-ts/exceptions - Update deployment for target runtime
Next Steps
- Getting Started - Set up your first application
- Plugin Architecture - Deep dive into plugins
- Examples - See real-world applications