kastor0.2.0
Get started

GUIDE 21Fastify

kastor with Fastify

Fastify is built around plugins and decorators, so the database belongs in a plugin that decorates the instance with a typed client and closes the pool in the onClose hook. Routes then use app.db with types inferred from your kastor schema.

Project structure

Fastify encapsulates plugins: a decorator added inside a plugin is only visible to that plugin's children, unless the plugin is wrapped with fastify-plugin. A database plugin should be wrapped, so the whole app shares one client.

Project
src/
  db/schema.ts            tables and relations
  plugins/db.ts           creates the client, decorates app.db, closes on onClose
  routes/posts.ts         routes using app.db
  app.ts                  builds the instance (used by tests too)
  server.ts               listen and signals
kastor.config.ts
kastor/migrations/
Terminal
npm install kastor-orm pg fastify fastify-plugin
npx kastor-orm init --db postgres
npx kastor migrate dev --name init

The plugin and its type

The plugin builds the client, decorates the instance and registers an onClose hook. The module augmentation tells TypeScript what app.db is, so routes get the full query types.

src/plugins/db.ts
import fp from 'fastify-plugin'
import type { FastifyInstance } from 'fastify'
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from '../db/schema'

export const createDb = (url: string) =>
  kastor({ adapter: postgres(new pg.Pool({ connectionString: url, max: 10 })), schema, relations })

export type Db = ReturnType<typeof createDb>

declare module 'fastify' {
  interface FastifyInstance {
    db: Db
  }
}

export default fp(async (app: FastifyInstance, opts: { url: string }) => {
  const db = createDb(opts.url)
  app.decorate('db', db)
  app.addHook('onClose', async () => {
    await db.$disconnect()
  })
}, { name: 'kastor' })

Routes with validation

Route schemas validate input before the handler runs and speed up serialization of the response. Inside the handler, app.db is the typed client.

src/routes/posts.ts
import type { FastifyInstance } from 'fastify'

export async function postRoutes(app: FastifyInstance) {
  app.get<{ Querystring: { take?: number } }>('/posts', async (req) =>
    app.db.posts.findMany({
      where: { published: true },
      include: { author: { select: { name: true } }, tags: true },
      orderBy: { id: 'desc' },
      take: Math.min(req.query.take ?? 20, 100),
    }),
  )

  app.post<{ Body: { title: string; authorId: number } }>('/posts', {
    schema: {
      body: {
        type: 'object',
        required: ['title', 'authorId'],
        properties: { title: { type: 'string', minLength: 3 }, authorId: { type: 'integer' } },
      },
    },
  }, async (req, reply) => {
    const post = await app.db.posts.create({ data: req.body })
    reply.code(201)
    return post
  })

  app.get('/authors/top', async () =>
    app.db.users.findMany({
      include: { _count: { select: { posts: true } } },
      orderBy: { posts: { _count: 'desc' } },
      take: 5,
    }),
  )
}
src/app.ts
import Fastify from 'fastify'
import { isKastorError } from 'kastor-orm'
import db from './plugins/db'
import { postRoutes } from './routes/posts'

const STATUS: Record<string, number> = { NOT_FOUND: 404, UNIQUE_VIOLATION: 409, FOREIGN_KEY_VIOLATION: 422 }

export function buildApp(url = process.env.DATABASE_URL!) {
  const app = Fastify({ logger: true })
  app.register(db, { url })
  app.register(postRoutes)
  app.setErrorHandler((error, req, reply) => {
    if (isKastorError(error) && STATUS[error.code]) {
      return reply.code(STATUS[error.code]!).send({ error: error.code, message: error.message })
    }
    reply.send(error)
  })
  return app
}

Shutdown, tests and migrations

Fastify's close() runs the onClose hook, so signals and tests clean up the pool the same way:

src/server.ts
import { buildApp } from './app'

const app = buildApp()
await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' })

for (const signal of ['SIGTERM', 'SIGINT'] as const) {
  process.on(signal, async () => {
    await app.close()
    process.exit(0)
  })
}

In tests, build the app against a test database, call app.inject(), and await app.close() in afterAll so the test runner does not hang on open connections. Migrations run before the server starts, in CI or a release step:

.github/workflows/deploy.yml
- run: npm ci
- run: npx tsc --noEmit
- run: npx vitest run
  env:
    DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- run: npx kastor migrate deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

For development, tsx watch --env-file=.env src/server.ts restarts the process on change; the signal handler closes the old pool first.

Why a plugin instead of a plain import

You could import the client from a module, as the Express guide does, and it would work. The plugin buys three things in a Fastify codebase. First, lifecycle: the onClose hook ties the pool to the instance, so every way of stopping the app (signals, tests, app.close() in a script) closes connections without extra code. Second, configuration: the plugin takes its options from register, so tests can build an app against a different database without touching environment variables or module mocks. Third, ordering: Fastify loads plugins in sequence and awaits each one, so the client exists before any route plugin runs.

Encapsulation is the part that surprises people. Fastify gives each plugin its own context; decorators added inside are visible to that plugin and its children only. Wrapping the database plugin with fastify-plugin opts out of that, which is right for shared infrastructure like a client. Route plugins, on the other hand, should stay encapsulated so their hooks and decorators do not leak into each other.

Transactions inside a request

A handler that writes to several tables should do it in one transaction: app.db.$transaction(async (tx) => ...) runs every call on one connection, commits when the callback resolves and rolls back when it throws. Keep the callback free of slow work such as HTTP calls to other services, because the connection stays checked out until it returns. When the writes do not depend on each other's results, the array form, app.db.$transaction([a, b]), is shorter and holds the connection for less time.

Running Fastify on a serverless platform, for example behind an adapter on AWS Lambda, changes only the pool size. Each function instance runs one copy of the app with its own pool, so set max to 1 or 2 and put a pooler in front of the database, as the Lambda guide explains.

Production checklist

Before the first deploy, check these points. Each one prevents a problem that otherwise shows up only under real traffic, usually as exhausted connections, a leaked secret or a schema that does not match the code that is running:

  • The database plugin is wrapped with fastify-plugin and registered before routes.
  • FastifyInstance is augmented, so app.db is typed.
  • The onClose hook disconnects the pool.
  • Routes declare body and response schemas.
  • Tests call app.close(), and CI runs migrations before release.

Pitfalls

  • Forgetting fastify-plugin. Without it, app.db exists only inside the plugin's own scope, and sibling route plugins see undefined.
  • Response schemas and bigint. Fastify's serializer drops fields missing from a response schema and cannot write bigint. Declare the fields you return and convert big keys to strings.
  • Registering order. Register the db plugin before the routes that use it; Fastify loads plugins in order.
  • Pool size. Fastify handles many concurrent requests on one process; size max for your real concurrency and the database limit, not the request rate.

Questions people ask

Q1How do I share a database client across Fastify plugins?

Wrap the setup in fastify-plugin so the decorator is not encapsulated, then app.decorate('db', db). Every plugin registered after it sees app.db.

Q2How do I type app.db?

Augment the FastifyInstance interface with declare module 'fastify' and set db to the type of your client. The query types then flow into every route.

Q3When should the pool close?

In an onClose hook. app.close() runs it, which happens on graceful shutdown and at the end of tests that use app.inject().

Q4Can I use Fastify's JSON schema validation with kastor?

Yes. Validate the request with a route schema; kastor then receives values the compiler and the runtime both trust. Use a type provider if you want the schema to type request.body too.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.