Edit this page on GitHub

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

ConceptFastifySetu-TS
RuntimeNode.js onlyDeno, Node.js, Bun, Cloudflare Workers
Request/ResponseFastifyRequest/FastifyReplyctx.request / ctx.response
Plugin Systemfastify.register()app.register(plugin)
EncapsulationPer-instance decorationNone: services are application-wide
Decoratorsfastify.decorate()Service registry (capability tokens)
SchemaJSON 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() with createApplication()
  • Replace app.get/post/put/delete with 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 with createTestApp({ app: createApp() })
  • Update logging to use LoggerPlugin
  • Replace setErrorHandler with errorHandler() from @setu-ts/exceptions
  • Update deployment for target runtime

Next Steps