OpenAPI + Fastify: let the contract build your server

Cover for OpenAPI + Fastify: let the contract build your server

Backend APIs usually start the same way: you write route handlers, add request validation, wire up auth. Afterward (if you’re disciplined) you generate an OpenAPI spec from all that code. The spec becomes a byproduct of the implementation — not a shared agreement you designed upfront. But what if you wrote the OpenAPI contract first and let it build the server for you?

This is part four of our contract-first series (part one | part two | part three), but works standalone if you’re here to wire OpenAPI into a Node.js backend.

In the previous article, we generated TypeScript types, built a React frontend with Nanostores, and mocked APIs with MSW—all flowing from the OpenAPI contract.

This time, we’ll do the backend side: a Fastify server where the contract defines routes, validates requests, and enforces types at compile time.

The complete working example is available at github.com/mikhin/martian-hotel-booking-backend.

Book a call

Hire Evil Martians

We'll build your contract-first backend so integration becomes a non-event.

Table of contents:

  1. Why Fastify for contract-first backend
  2. Generating backend types from your contract
  3. Wiring routes with fastify-openapi-glue
  4. Building the layered architecture
  5. Security and validation

Why Fastify for contract-first backend?

In a typical Express setup, routes are defined manually, request data is untyped, and response shapes rely on convention:

app.get('/hotels', async (req, res) => {
  const page = Number(req.query.page);
  const hotels = await db.hotel.findMany();
  res.json(hotels);
});

app.post('/hotels', async (req, res) => {
  const hotel = await db.hotel.create({ data: req.body });
  res.status(201).json(hotel);
});

This works, but it means:

  • Routes can drift from the spec — a typo in the path goes unnoticed until production
  • No request validation — malformed payloads reach the database layer
  • Types are manual — you cast req.body as HotelUpsert and hope for the best
  • Boilerplate repeats — same auth check, same pagination, same error handling in every route

Fastify with fastify-openapi-glue takes a different approach: the OpenAPI spec becomes the router definition: you provide handler functions, and the plugin matches them to operations by operationId. No app.get() calls or manual route registration needed.

Scaffolding the project

$ mkdir martian-hotel-server && cd martian-hotel-server
$ npm init -y
$ npm install fastify fastify-openapi-glue @fastify/jwt
$ npm install prisma @prisma/client
$ npm install -D typescript tsx @types/node @hey-api/openapi-ts

The key packages are as follows:

  • fastify — HTTP framework with built-in TypeScript support
  • fastify-openapi-glue — reads your OpenAPI spec and wires routes to handler functions
  • @fastify/jwt — JWT authentication
  • prisma — type-safe ORM

Generating backend types from your contract

In article three, we used Hey API to generate TypeScript types and SDK functions for the frontend. The same tool generates backend types, including typed Fastify route handlers.

Add the "fastify" plugin to your Hey API config:

// openapi-ts.config.ts
import { defineConfig } from "@hey-api/openapi-ts";

export default defineConfig({
  input: "https://martian-hotel-booking-api.vercel.app/output.yml",
  output: {
    clean: true,
    format: "prettier",
    lint: "eslint",
    path: "sdk",
  },
  plugins: [
    "@hey-api/client-fetch",
    {
      name: "@hey-api/sdk",
    },
    {
      enums: "javascript",
      name: "@hey-api/typescript",
    },
    "zod",
    "fastify",  // generates typed route handlers
  ],
});

Run npm run generate-api and Hey API produces a fastify.gen.ts file with a RouteHandlers type, which is a complete interface describing every handler your server needs:

// sdk/fastify.gen.ts (auto-generated)
import type { RouteHandler } from "fastify";
import type {
  GetHotelsData,
  GetHotelsResponses,
  CreateHotelData,
  CreateHotelResponses,
  GetHotelByIdData,
  GetHotelByIdErrors,
  GetHotelByIdResponses,
  UpdateHotelData,
  UpdateHotelErrors,
  UpdateHotelResponses,
  DeleteHotelData,
  DeleteHotelErrors,
  // ... every operation in your spec
} from "./types.gen";

export type RouteHandlers = {
  getHotels: RouteHandler<{
    Querystring: GetHotelsData["query"];
    Reply: GetHotelsResponses;
  }>;
  createHotel: RouteHandler<{
    Body: CreateHotelData["body"];
    Reply: CreateHotelResponses;
  }>;
  getHotelById: RouteHandler<{
    Params: GetHotelByIdData["path"];
    Reply: GetHotelByIdErrors & GetHotelByIdResponses;
  }>;
  updateHotel: RouteHandler<{
    Body: UpdateHotelData["body"];
    Params: UpdateHotelData["path"];
    Reply: UpdateHotelErrors & UpdateHotelResponses;
  }>;
  deleteHotel: RouteHandler<{
    Params: DeleteHotelData["path"];
    Reply: DeleteHotelErrors;
  }>;
  login: RouteHandler<{
    Body: LoginData["body"];
    Reply: LoginErrors & LoginResponses;
  }>;
  // ... every operation in your OpenAPI spec
};

Each handler gets typed generics for Querystring, Params, Body, and Reply. These are the same types your frontend uses: GetHotelsData["query"] types both the frontend SDK call and the backend handler’s request.query.

Wiring routes with fastify-openapi-glue

With fastify-openapi-glue, you don’t register routes manually. Instead, the plugin reads your spec and expects a serviceHandlers object with handler functions keyed by operationId:

// src/index.ts
import type { RouteHandlers } from '../sdk/fastify.gen.js';

import { PrismaClient } from '@prisma/client';
import fastifyJwt from '@fastify/jwt';
import fastify from 'fastify';
import openapiGlue from 'fastify-openapi-glue';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

import { createAuthController } from './modules/auth/controller';
import { createBookingsController } from './modules/bookings/controller';
import { createHotelsController } from './modules/hotels/controller';
import { SecurityHandlers } from './plugins/security-handlers';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const prisma = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL! });

const init = async (): Promise<void> => {
  const server = fastify({
    logger: true,
  });

  await prisma.$connect();
  server.addHook('onClose', async () => { await prisma.$disconnect(); });

  server.register(fastifyJwt, {
    secret: process.env.JWT_SECRET!,
  });

  const serviceHandlers: RouteHandlers = {
    ...createAuthController(server),
    ...createHotelsController(prisma),
    ...createBookingsController(prisma),
  };

  await server.register(openapiGlue, {
    securityHandlers: new SecurityHandlers(),
    serviceHandlers,
    specification: path.resolve(__dirname, '../specs/output.yml'),
  });

  server.listen({ host: '0.0.0.0', port: 8080 });
};

init();

A few things to note:

No route definitions. The specification property points to a local copy of the OpenAPI YAML—the same spec your Hey API config fetches from the remote URL for type generation. Download it once (or as part of your build) and commit it to specs/output.yml. fastify-openapi-glue reads it at startup and creates Fastify routes from it automatically.

serviceHandlers is typed as RouteHandlers. Each controller factory returns a subset of handlers. If a handler is missing or has the wrong signature, TypeScript catches it at compile time.

securityHandlers is separate. The OpenAPI spec declares which endpoints need bearerAuth. The SecurityHandlers class implements verification once; the plugin calls it automatically for protected routes.

How operationId becomes a handler name

The mapping is through operationId in the spec:

paths:
  /hotels:
    get:
      operationId: getHotels
      summary: Get all hotels

fastify-openapi-glue looks for serviceHandlers.getHotels. The same operationId drove the function name in the generated frontend SDK (getHotels() in article three).

One operationId serves three roles: route mapping on the backend, SDK function name on the frontend, and handler type in the generated interface.

Building the layered architecture

With routes wired by the spec, we still need to implement the handlers. To illustrate:

  • The contract says createdAt is an ISO string—your database returns a Date object.

  • The contract says the response is { id, name, location, status, createdAt }—your DB row might include extra columns, different naming, or types that don’t serialize the way the spec expects.

You can’t just send the raw result straight to the client. Something has to close this gap and translate between what your data sources give you (database rows, third-party API responses, cache entries) and what the contract promises the client.

You could do it all in the handler, but that gets messy fast: HTTP concerns (status codes, headers) tangled with data mapping tangled with query logic.

Splitting into layers—a classic separation of
concerns from Clean Architecture—keeps each responsibility isolated: the Repository talks to the database, the Service maps DB results to contract shapes, and the Controller extracts request params and sends responses.

The repository layer

The repository handles database interactions; it knows about database types but nothing about HTTP or the API contract:

// src/modules/hotels/repository.ts
import type { Hotel, Prisma } from '@prisma/client';
import { PrismaClient } from '@prisma/client';

export type { Hotel as HotelDB };

export class HotelRepository {
  constructor(private prisma: PrismaClient) {
    this.prisma = prisma;
  }

  async count(): Promise<number> {
    return this.prisma.hotel.count();
  }

  async create(data: Prisma.HotelCreateInput): Promise<Hotel> {
    return this.prisma.hotel.create({ data });
  }

  async findById(id: string): Promise<Hotel> {
    return this.prisma.hotel.findUniqueOrThrow({
      where: { id },
    });
  }

  async findMany(skip: number, take: number): Promise<Hotel[]> {
    return this.prisma.hotel.findMany({ skip, take });
  }

  async update(id: string, data: Prisma.HotelUpdateInput): Promise<Hotel> {
    return this.prisma.hotel.update({
      data,
      where: { id },
    });
  }

  async delete(id: string): Promise<void> {
    await this.prisma.hotel.delete({
      where: { id },
    });
  }
}

The HotelDB export alias separates the database type from the API type. They look similar but aren’t identical (e.g., dates are Date objects in Prisma and ISO strings in the API).

The service layer

The service layer converts database models to API models:

// src/modules/hotels/service.ts
import {
  CreateHotelData,
  CreateHotelResponse,
  GetHotelsData,
  GetHotelsResponses,
  Hotel as HotelAPI,
  UpdateHotelData,
  UpdateHotelResponse,
} from '../../../sdk/types.gen.js';

import { HotelDB, HotelRepository } from './repository.js';

export class HotelService {
  constructor(private hotelRepository: HotelRepository) {}

  async createHotel(
    data: CreateHotelData['body'],
  ): Promise<CreateHotelResponse> {
    const entity = await this.hotelRepository.create(data);

    return this.mapDBToAPI(entity);
  }

  async getHotelById(id: string): Promise<HotelAPI> {
    const entity = await this.hotelRepository.findById(id);

    return this.mapDBToAPI(entity);
  }

  async getHotels(
    query: GetHotelsData['query'],
  ): Promise<GetHotelsResponses['200']> {
    const skip = (query.page - 1) * query.pageSize;

    const [entities, totalItems] = await Promise.all([
      this.hotelRepository.findMany(skip, query.pageSize),
      this.hotelRepository.count(),
    ]);

    const items = entities.map((entity) => this.mapDBToAPI(entity));

    return {
      currentPage: query.page,
      items,
      pageSize: query.pageSize,
      totalItems,
      totalPages: Math.ceil(totalItems / query.pageSize),
    };
  }

  async updateHotel(
    id: string,
    data: UpdateHotelData['body'],
  ): Promise<UpdateHotelResponse> {
    const entity = await this.hotelRepository.update(id, data);

    return this.mapDBToAPI(entity);
  }

  async deleteHotel(id: string): Promise<void> {
    await this.hotelRepository.delete(id);
  }

  private mapDBToAPI(entity: HotelDB): HotelAPI {
    return {
      createdAt: entity.createdAt.toISOString(),
      id: entity.id,
      location: entity.location,
      name: entity.name,
      status: entity.status,
    };
  }
}

Here, CreateHotelData['body'] is the same type the frontend sends—one generated file types both sides of the contract.

The mapDBToAPI method is where contract compliance becomes visible. If the spec adds a field tomorrow, TypeScript flags the incomplete mapping here—not at runtime, nor in production.

The controller factory

The controller factory creates dependencies and returns typed handler functions:

// src/modules/hotels/controller.ts
import type { RouteHandlers } from '../../../sdk/fastify.gen.js';
import type {
  CreateHotelData,
  CreateHotelResponse,
  GetHotelByIdData,
  GetHotelsData,
  Hotel,
  PaginatedResponse,
  UpdateHotelData,
} from '../../../sdk/types.gen.js';
import { PrismaClient } from '@prisma/client';

import { HotelRepository } from './repository.js';
import { HotelService } from './service.js';

export class HotelsController {
  constructor(private hotelService: HotelService) {}

  async createHotel(
    data: CreateHotelData['body'],
  ): Promise<CreateHotelResponse> {
    return this.hotelService.createHotel(data);
  }

  async getHotelById(
    id: GetHotelByIdData['path']['id'],
  ): Promise<Hotel> {
    return this.hotelService.getHotelById(id);
  }

  async getHotels(
    query: GetHotelsData['query'],
  ): Promise<PaginatedResponse & { items: Hotel[] }> {
    return this.hotelService.getHotels(query);
  }

  async updateHotel(
    id: string,
    data: UpdateHotelData['body'],
  ): Promise<Hotel> {
    return this.hotelService.updateHotel(id, data);
  }

  async deleteHotel(id: string): Promise<void> {
    return this.hotelService.deleteHotel(id);
  }
}

export const createHotelsController = (
  prisma: PrismaClient,
): Pick<
  RouteHandlers,
  | 'createHotel'
  | 'deleteHotel'
  | 'getHotelById'
  | 'getHotels'
  | 'updateHotel'
> => {
  const hotelRepository = new HotelRepository(prisma);
  const hotelService = new HotelService(hotelRepository);
  const controller = new HotelsController(hotelService);

  return {
    createHotel: async (request, reply) => {
      const result = await controller.createHotel(request.body);

      return reply.status(201).send(result);
    },
    deleteHotel: async (request, reply) => {
      await controller.deleteHotel(request.params.id);

      return reply.status(204).send();
    },
    getHotelById: async (request, reply) => {
      const result = await controller.getHotelById(request.params.id);

      return reply.status(200).send(result);
    },
    getHotels: async (request, reply) => {
      const result = await controller.getHotels(request.query);

      return reply.status(200).send(result);
    },
    updateHotel: async (request, reply) => {
      const result = await controller.updateHotel(
        request.params.id,
        request.body,
      );

      return reply.status(200).send(result);
    },
  };
};

The return type Pick<RouteHandlers, 'createHotel' | 'getHotelById' | ...> tells TypeScript which handlers this controller provides. When spread into serviceHandlers in index.ts, TypeScript verifies every handler in RouteHandlers is accounted for. Missing handler …compile error. Wrong signature …compile error.

Fastify doesn’t ship with a dependency injection container —and for most projects, you don’t need one. The factory pattern gives you the same result: receive dependencies, build the chain, return handler functions.

For testing, swap in a mock database client and call handlers directly.

Each handler stays thin: extract params, call the controller, set status, send response. The business logic lives in the service and repository layers.

request.body, request.params, request.query are all typed by the generated types. No casting needed.

The module structure

src/modules/
├── hotels/
│   ├── controller.ts    # HTTP layer — extracts params, sends responses
│   ├── service.ts       # Business logic — DB-to-API mapping, orchestration
│   └── repository.ts    # Data access — Prisma queries only
├── bookings/
│   ├── controller.ts
│   ├── service.ts
│   └── repository.ts
└── auth/
    ├── controller.ts
    └── service.ts

Every module follows the same structure. Adding a new feature means copying the pattern and implementing handlers.

Security and Validation

JWT authentication from the contract

The OpenAPI spec declares security requirements:

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

And per-endpoint:

paths:
  /hotels:
    get:
      security:
        - bearerAuth: []
  /auth/login:
    post:
      security: []  # Public

fastify-openapi-glue reads these and calls your security handler automatically:

// src/plugins/security-handlers.ts
import { FastifyRequest } from 'fastify';

export class SecurityHandlers {
  async bearerAuth(request: FastifyRequest): Promise<void> {
    await request.jwtVerify();
  }
}

That’s the entire file! request.jwtVerify() from @fastify/jwt checks the Authorization: Bearer <token> header and throws if invalid. The plugin calls this before your handler runs for any endpoint with bearerAuth in the spec. Endpoints with security: [] skip it.

No auth checks in individual route handlers. The spec controls which endpoints are protected.

Request validation

fastify-openapi-glue validates incoming requests against the spec automatically. When the spec says:

parameters:
  - name: page
    in: query
    required: true
    schema:
      type: integer
      minimum: 1
  - name: pageSize
    in: query
    required: true
    schema:
      type: integer
      minimum: 1
      maximum: 100

A request like GET /hotels?page=0&pageSize=999 gets rejected with a 400 Bad Request before the handler runs. This covers required fields, type checking, format validation (e.g., UUID), and enum values.

No custom validation code needed—handlers only receive valid, typed data.

One asymmetry worth noting: requests are validated at both runtime (the plugin rejects bad input before your handler runs) and compile time (TypeScript types request.query, request.body, etc.). Responses are only validated at compile time—the Reply types in the generated RouteHandlers interface make a wrong return shape a TypeScript error, but nothing catches it at runtime.

The request lifecycle

Let’s walk through an example: the PUT /hotels/abc-123 request flows through these steps:

  1. fastify-openapi-glue matches the path to operationId: updateHotel
  2. The security handler verifies the JWT token
  3. Request validation checks the body against the spec schema
  4. serviceHandlers.updateHotel(request, reply) runs
  5. The controller extracts request.params.id and request.body, calls hotelService.updateHotel()
  6. Service calls hotelRepository.update(), maps the result with mapDBToAPI()
  7. On success, the handler returns reply.status(200).send(result)

Route definitions, validation, and auth are all derived from the spec.

Summing up what we’ve built

So, what do we end up with?

A Fastify backend where fastify-openapi-glue wires routes from the OpenAPI spec, Hey API generates typed handler interfaces, and a layered architecture separates HTTP, business logic, and database concerns.

We also have request validation and JWT authentication flow from the spec rather than from code repeated across routes.

The same OpenAPI spec that generates frontend types and MSW mocks on the frontend now generates route handlers and validates requests on the backend. Change the contract, regenerate both sides, fix the type errors.

Book a call

Irina Nazarova CEO at Evil Martians

Hire Evil Martians to build contract-first backends that eliminate integration chaos.