Fastify #

Fastify adalah framework web Node.js yang lahir dengan satu janji: performa tinggi tanpa mengorbankan developer experience. Benchmark internal menunjukkan Fastify bisa melayani hingga 30+ ribu request per detik pada hardware standar — 2-3x lebih cepat dari Express. Tapi performa bukan satu-satunya alasan developer berpindah ke Fastify. Yang membuat Fastify istimewa adalah schema-first philosophy: dengan mendefinisikan JSON Schema untuk route, kamu mendapat validasi otomatis, serialisasi cepat (5x lebih cepat dari JSON.stringify), dan dokumentasi OpenAPI gratis.

Untuk local development, Fastify lebih ringan dari NestJS dan lebih terstruktur dari Express — sweet spot di tengah. Hot reload bisa pakai tsx watch (TypeScript) atau nodemon (JavaScript), dan schema validation sudah built-in. Docker Compose menyatukan toolchain dan service pendukung, sehingga semua developer punya environment identik.

Artikel ini membahas setup Docker Compose untuk Fastify secara menyeluruh: Dockerfile multi-stage, hot reload, schema validation dengan JSON Schema dan Zod, plugin system, hooks, TypeScript, Prisma integration, dan best practice performance.

Prasyarat #

Pastikan sudah terinstall di host:

  • Docker dan Docker Compose versi terbaru (v2.20+)
  • Node.js 20+ — runtime minimum
  • TypeScript 5+ — opsional, tapi sangat direkomendasikan untuk type safety
  • curl atau HTTPie — testing endpoint

Struktur project Fastify yang diasumsikan:

fastify-app/
├── src/
│   ├── server.ts           # factory function
│   ├── app.ts              # build app dengan routes
│   ├── routes/
│   │   ├── users.ts
│   │   ├── auth.ts
│   │   └── health.ts
│   ├── plugins/
│   │   ├── prisma.ts
│   │   ├── redis.ts
│   │   └── auth.ts
│   ├── schemas/
│   │   ├── user.ts
│   │   └── common.ts
│   ├── lib/
│   │   ├── errors.ts
│   │   └── logger.ts
│   └── types/
│       └── fastify.d.ts
├── test/
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env
├── tsconfig.json
├── package.json
└── package-lock.json
Pola server.ts + app.ts adalah best practice Fastify. server.ts adalah factory yang membuat instance Fastify dengan konfigurasi lengkap. app.ts adalah entry point yang listen. Pola ini memudahkan testing: test bisa build aplikasi tanpa listen, lalu inject() request langsung ke dalam memori.

Dockerfile Multi-Stage #

Fastify ditulis dalam TypeScript modern dengan ECMAScript modules. Untuk development, kita jalankan via tsx watch — tool TypeScript execution dengan hot reload. Untuk production, kita compile dulu dengan tsc atau tsup, lalu jalankan Node di image ramping.

# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps

WORKDIR /app

# Salin manifest lebih dulu untuk cache layer
COPY package.json package-lock.json ./

# Install semua dependency (termasuk devDependencies)
RUN npm install --no-audit --no-fund

# Salin konfigurasi TypeScript
COPY tsconfig.json ./

# Salin source code
COPY src ./src

# ----- Production builder -----
FROM deps AS builder

RUN npm run build

# ----- Production runner -----
FROM node:20-alpine AS runner

WORKDIR /app

ENV NODE_ENV=production \
    PORT=3000

RUN addgroup -S app && adduser -S app -G app

# Salin hanya dependency production
COPY package.json package-lock.json ./
RUN npm install --omit=dev --no-audit --no-fund

# Salin hasil build
COPY --from=builder --chown=app:app /app/dist ./dist

USER app

EXPOSE 3000

CMD ["node", "dist/server.js"]

Penjelasan Tahapan #

Stage deps — base development. Meng-install tsx, typescript, prisma, dan devDependencies lain. tsx watch membaca file TypeScript langsung tanpa compile manual.

Stage builder — menjalankan npm run build (yang menjalankan tsc atau tsup) untuk menghasilkan output dist/.

Stage runner — image ramping. Hanya dependency production dan dist/. User non-root untuk security.

Image node:20-alpine sudah termasuk glibc yang kompatibel dengan binary module seperti bcrypt, sharp, dan prisma. Untuk module yang sangat platform-specific, kadang perlu node:20-bookworm-slim (Debian-based). Cek dokumentasi module yang kamu pakai.

docker-compose.yml untuk Development #

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: deps
    image: fastify-app:dev
    container_name: fastify-dev
    command: npm run dev
    working_dir: /app
    ports:
      - "3000:3000"
      - "5555:5555"           # Prisma Studio (opsional)
    volumes:
      - ./src:/app/src
      - ./test:/app/test
      - /app/node_modules
      - /app/dist
    environment:
      NODE_ENV: development
      PORT: 3000
      DATABASE_URL: postgres://app:dev@db:5432/fastify_dev
      REDIS_URL: redis://cache:6379
      LOG_LEVEL: info
      JWT_SECRET: dev-secret-change-me
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: fastify-db
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: fastify_dev
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d fastify_dev"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: fastify-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    ports:
      - "6379:6379"

volumes:
  db-data:

Penjelasan Service #

api — service Fastify. target: deps memilih stage development. command: npm run dev menjalankan tsx watch src/server.ts — setiap perubahan file TypeScript di-mount dari host akan trigger restart otomatis.

Anonymous volumes untuk node_modules dan dist — keduanya wajib. node_modules di container berisi binary module yang di-compile untuk platform container, bukan host. dist di-mount sebagai anonymous volume untuk mencegah tumpang tindih dengan tsx watch yang mengelola hot reload sendiri.

Port 5555 — untuk Prisma Studio (GUI database) yang bisa dijalankan via npx prisma studio. Opsional, bisa dihapus kalau tidak dipakai.

db dan cache — Postgres dan Redis dengan healthcheck. App start dengan condition: service_healthy.

Diagram Lifecycle #

sequenceDiagram
    participant Dev as Developer
    participant VSC as VS Code
    participant Bind as Bind Mount
    participant App as Container Fastify
    participant DB as Postgres
    Dev->>VSC: Edit src/routes/users.ts
    VSC->>Bind: Write to host FS
    Bind->>App: Sync ke container
    App->>App: tsx watch detect perubahan
    App->>App: Restart module yang berubah
    App->>DB: Connection dari pool tetap hidup
    Note over Dev,DB: Tidak perlu rebuild image,<br/>tidak perlu restart container

Hot Reload dengan tsx watch #

Untuk TypeScript project, tsx adalah pilihan paling ringan. Ia menjalankan TypeScript langsung (transpile only) tanpa cache, dengan watch mode built-in. Alternatif lain: ts-node-dev (lebih lama, lebih banyak fitur), atau nodemon + tsx (paling fleksibel).

package.json:

{
  "name": "fastify-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/server.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "db:studio": "prisma studio",
    "db:migrate": "prisma migrate dev"
  },
  "dependencies": {
    "fastify": "^4.28.1",
    "@fastify/cors": "^9.0.1",
    "@fastify/helmet": "^11.1.1",
    "@fastify/rate-limit": "^9.1.0",
    "@fastify/jwt": "^8.0.1",
    "@fastify/swagger": "^8.15.0",
    "@fastify/swagger-ui": "^4.1.0",
    "fastify-type-provider-zod": "^2.0.0",
    "zod": "^3.23.8",
    "@prisma/client": "^5.18.0",
    "ioredis": "^5.4.1",
    "pino-pretty": "^11.2.2"
  },
  "devDependencies": {
    "tsx": "^4.16.5",
    "typescript": "^5.5.4",
    "prisma": "^5.18.0",
    "vitest": "^2.0.5",
    "@types/node": "^20.14.12"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": false,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "test"]
}

tsconfig.build.json (untuk production build, exclude test):

{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

.dockerignore:

node_modules
dist
coverage
.git
.gitignore
.env
.env.local
Dockerfile
docker-compose.yml
.dockerignore
README.md
test
*.log
prisma/migrations/dev.db*

Server dan App Entry Point #

Pemisahan server.ts (factory) dan app.ts (build) memudahkan testing. Test bisa build tanpa listen, lalu inject() request langsung.

src/server.ts — factory:

import Fastify, { FastifyInstance } from 'fastify';
import { app } from './app.js';

export async function build(): Promise<FastifyInstance> {
  return app();
}

async function start() {
  const server = await build();

  const port = Number(process.env.PORT) || 3000;
  const host = '0.0.0.0';

  try {
    await server.listen({ port, host });
    server.log.info(`Fastify listening on http://${host}:${port}`);
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
}

start();

src/app.ts — aplikasi dengan plugin dan routes:

import Fastify, { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import jwt from '@fastify/jwt';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';

import { userRoutes } from './routes/users.js';
import { authRoutes } from './routes/auth.js';
import { healthRoutes } from './routes/health.js';
import prismaPlugin from './plugins/prisma.js';
import redisPlugin from './plugins/redis.js';

export async function app(): Promise<FastifyInstance> {
  const fastify = Fastify({
    logger: {
      level: process.env.LOG_LEVEL || 'info',
      transport: process.env.NODE_ENV === 'development'
        ? { target: 'pino-pretty' }
        : undefined,
    },
    requestIdHeader: 'x-request-id',
    requestIdLogLabel: 'reqId',
    disableRequestLogging: false,
    trustProxy: true,
  });

  // Security dan middleware global
  await fastify.register(helmet, { contentSecurityPolicy: false });
  await fastify.register(cors, {
    origin: process.env.CORS_ORIGIN || '*',
    credentials: true,
  });
  await fastify.register(rateLimit, {
    max: 100,
    timeWindow: '1 minute',
  });

  // Database dan cache
  await fastify.register(prismaPlugin);
  await fastify.register(redisPlugin);

  // JWT auth
  await fastify.register(jwt, {
    secret: process.env.JWT_SECRET || 'dev-secret',
  });

  // OpenAPI docs
  await fastify.register(swagger, {
    openapi: {
      info: {
        title: 'Fastify API',
        description: 'API documentation',
        version: '1.0.0',
      },
      servers: [{ url: 'http://localhost:3000' }],
    },
  });
  await fastify.register(swaggerUi, {
    routePrefix: '/docs',
  });

  // Routes
  await fastify.register(healthRoutes, { prefix: '/health' });
  await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
  await fastify.register(userRoutes, { prefix: '/api/v1/users' });

  return fastify;
}
fastify.register() berbeda dari app.use() di Express. Register membuat encapsulated context — plugin atau route yang di-register di sini hanya berlaku di scope tersebut, tidak bocor ke scope lain. Ini salah satu fitur paling powerful di Fastify: setiap route bisa punya plugin sendiri tanpa konflik global.

Schema Validation — Jantungnya Fastify #

Fastify menggunakan JSON Schema secara native untuk memvalidasi body, params, querystring, dan headers — performanya 2-3x lebih cepat dari validator lain. Bonusnya, schema ini sekaligus menjadi dokumentasi OpenAPI otomatis via @fastify/swagger.

Pendekatan 1: JSON Schema Native #

src/schemas/user.ts:

export const userSchema = {
  type: 'object',
  properties: {
    id: { type: 'integer' },
    email: { type: 'string', format: 'email' },
    name: { type: 'string' },
    role: { type: 'string', enum: ['user', 'admin'] },
    createdAt: { type: 'string', format: 'date-time' },
  },
} as const;

export const createUserSchema = {
  body: {
    type: 'object',
    required: ['email', 'name', 'password'],
    properties: {
      email: { type: 'string', format: 'email' },
      name: { type: 'string', minLength: 2, maxLength: 100 },
      password: { type: 'string', minLength: 8, maxLength: 128 },
      role: { type: 'string', enum: ['user', 'admin'], default: 'user' },
    },
    additionalProperties: false,    // tolak field tambahan
  },
  response: {
    201: userSchema,
  },
} as const;

src/routes/users.ts:

import { FastifyPluginAsync } from 'fastify';
import { createUserSchema, userSchema } from '../schemas/user.js';

export const userRoutes: FastifyPluginAsync = async (fastify) => {
  // List users
  fastify.get('/', {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
          offset: { type: 'integer', minimum: 0, default: 0 },
        },
      },
      response: {
        200: {
          type: 'object',
          properties: {
            data: { type: 'array', items: userSchema },
            total: { type: 'integer' },
          },
        },
      },
    },
  }, async (request) => {
    const { limit = 20, offset = 0 } = request.query;
    const [data, total] = await fastify.prisma.user.findAndCount({
      take: limit,
      skip: offset,
    });
    return { data, total };
  });

  // Create user
  fastify.post('/', { schema: createUserSchema }, async (request, reply) => {
    const { email, name, password, role } = request.body;
    const passwordHash = await fastify.bcrypt.hash(password);

    const user = await fastify.prisma.user.create({
      data: { email, name, passwordHash, role },
    });

    reply.code(201);
    return user;
  });
};

Pendekatan 2: Zod (Type-Safe) #

Untuk developer yang lebih suka Zod, fastify-type-provider-zod mengkonversi Zod schema ke JSON Schema otomatis. TypeScript inference membuat request.body punya tipe yang benar.

src/routes/users-zod.ts:

import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { ZodTypeProvider } from '../types/fastify.js';

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  password: z.string().min(8).max(128),
  role: z.enum(['user', 'admin']).default('user'),
});

const userResponse = z.object({
  id: z.number().int(),
  email: z.string().email(),
  name: z.string(),
  role: z.enum(['user', 'admin']),
  createdAt: z.string().datetime(),
});

export const userRoutesZod: FastifyPluginAsync = async (fastify) => {
  // Cast ke type provider
  const app = fastify.withTypeProvider<ZodTypeProvider>();

  app.post('/', {
    schema: {
      body: createUserSchema,
      response: { 201: userResponse },
    },
  }, async (request) => {
    // request.body otomatis typed sebagai z.infer<typeof createUserSchema>
    const { email, name, password, role } = request.body;
    const passwordHash = await fastify.bcrypt.hash(password);

    const user = await app.prisma.user.create({
      data: { email, name, passwordHash, role },
    });

    return user;
  });
};

src/types/fastify.d.ts — augment tipe untuk decorator Prisma, Redis, dll:

import 'fastify';
import { PrismaClient } from '@prisma/client';
import type { Redis } from 'ioredis';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';

declare module 'fastify' {
  interface FastifyInstance {
    prisma: PrismaClient;
    redis: Redis;
    bcrypt: {
      hash: (data: string) => Promise<string>;
      compare: (data: string, hash: string) => Promise<boolean>;
    };
  }

  interface FastifyRequest {
    user?: { id: number; role: string };
  }
}

Tabel Perbandingan Pendekatan Validasi #

Aspek JSON Schema Native Zod (via type provider)
Performa Paling cepat Sedikit lebih lambat (konversi)
Type safety Manual type Automatic dari schema
Learning curve Perlu tahu JSON Schema Familiar untuk TS devs
OpenAPI generation Built-in Built-in
Reusability Bisa di-import Bisa di-import
Cocok untuk Performa tinggi, schema kompleks Tim TS, validasi kompleks
Gunakan additionalProperties: false di JSON Schema atau .strict() di Zod. Tanpa ini, field tambahan di request body akan lolos validasi. Untuk API publik, ini celah keamanan — client bisa menyisipkan field yang tidak kamu antisipasi.

Plugin System #

Plugin adalah cara Fastify mengemas reusable code. Plugin bisa di-register dengan fastify.register(), dan setiap register menciptakan encapsulated context. Ini membuat testing dan modularity jauh lebih bersih dibanding Express.

src/plugins/prisma.ts — plugin Prisma:

import fp from 'fastify-plugin';
import { PrismaClient } from '@prisma/client';

declare module 'fastify' {
  interface FastifyInstance {
    prisma: PrismaClient;
  }
}

async function prismaPlugin(fastify: any) {
  const prisma = new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'error', 'warn']
      : ['error'],
  });

  await prisma.$connect();

  fastify.decorate('prisma', prisma);

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

export default fp(prismaPlugin, { name: 'prisma' });

src/plugins/redis.ts — plugin Redis:

import fp from 'fastify-plugin';
import Redis from 'ioredis';

declare module 'fastify' {
  interface FastifyInstance {
    redis: Redis;
  }
}

async function redisPlugin(fastify: any) {
  const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
    maxRetriesPerRequest: 3,
    enableReadyCheck: true,
  });

  redis.on('error', (err) => {
    fastify.log.error({ err }, 'Redis connection error');
  });

  fastify.decorate('redis', redis);

  fastify.addHook('onClose', async () => {
    await redis.quit();
  });
}

export default fp(redisPlugin, { name: 'redis' });

fastify-plugin (fp) membuat plugin “lolos” dari encapsulation. Tanpa fp, decorator prisma dan redis hanya tersedia di scope plugin. Dengan fp, decorator tersedia di scope atas (di mana plugin di-register). Untuk plugin infrastructure seperti database dan cache, selalu pakai fp.

Diagram Plugin Lifecycle #

stateDiagram-v2
    [*] --> Created: fastify = Fastify()
    Created --> Registering: register(prismaPlugin)
    Registering --> Registered: decorate('prisma', client)
    Registered --> Listening: app.listen(3000)
    Listening --> Running: request in
    Running --> Listening: response out
    Listening --> Closing: SIGTERM/SIGINT
    Closing --> Cleanup: onClose hook
    Cleanup --> Disconnected: prisma.$disconnect()
    Disconnected --> [*]

Hooks — Preprocessing dan Postprocessing #

Hook adalah fungsi yang berjalan pada titik-titik tertentu dalam lifecycle request. Fastify punya banyak hook, yang paling umum:

Hook Waktu Cocok Untuk
onRequest Sebelum routing Auth, rate limit
preParsing Sebelum body parse Validasi content-type
preValidation Sebelum schema validasi Transformasi input
preHandler Setelah validasi Set context, cek permission
preSerialization Sebelum serialize Transformasi output
onSend Sebelum kirim response Logging, header
onResponse Setelah response dikirim Audit log
onError Saat error Custom error response
onClose Saat app shutdown Cleanup

src/plugins/auth.ts — hook auth:

import fp from 'fastify-plugin';
import jwt from 'jsonwebtoken';

export default fp(async (fastify) => {
  fastify.decorate('authenticate', async (request: any, reply: any) => {
    try {
      await request.jwtVerify();
      request.user = request.user as { id: number; role: string };
    } catch (err) {
      reply.code(401).send({ error: 'Unauthorized', message: 'Token tidak valid' });
    }
  });
});

declare module 'fastify' {
  interface FastifyInstance {
    authenticate: (request: any, reply: any) => Promise<void>;
  }
}

Pakai di route:

fastify.get('/me', { preHandler: [fastify.authenticate] }, async (request) => {
  return fastify.prisma.user.findUnique({ where: { id: request.user.id } });
});

Error Handling #

Fastify punya built-in error handler. Untuk custom error, override via setErrorHandler. Throw custom error dengan errorCode yang konsisten.

src/lib/errors.ts:

export class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: unknown,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export class NotFoundError extends AppError {
  constructor(message = 'Resource tidak ditemukan') {
    super(404, 'NotFound', message);
  }
}

export class ValidationError extends AppError {
  constructor(message: string, details?: unknown) {
    super(400, 'ValidationError', message, details);
  }
}

export class ConflictError extends AppError {
  constructor(message = 'Resource conflict') {
    super(409, 'Conflict', message);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = 'Tidak terautentikasi') {
    super(401, 'Unauthorized', message);
  }
}

Custom error handler di app.ts:

fastify.setErrorHandler((error, request, reply) => {
  // Validation error dari schema
  if (error.validation) {
    return reply.code(400).send({
      error: 'ValidationError',
      message: error.message,
      details: error.validation,
    });
  }

  // Custom AppError
  if (error instanceof AppError) {
    request.log.warn({ err: error }, 'application error');
    return reply.code(error.statusCode).send({
      error: error.code,
      message: error.message,
      ...(error.details ? { details: error.details } : {}),
    });
  }

  // Default — log lengkap di server, response generik di client
  request.log.error({ err: error }, 'unhandled error');
  return reply.code(500).send({
    error: 'InternalServerError',
    message: 'Something went wrong',
  });
});

Prisma Integration #

Prisma adalah ORM TypeScript-first yang sangat cocok dengan Fastify. Type generation otomatis, query builder yang aman dari SQL injection, dan migration tool bawaan.

prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id           Int      @id @default(autoincrement())
  email        String   @unique
  name         String
  passwordHash String   @map("password_hash")
  role         String   @default("user")
  createdAt    DateTime @default(now()) @map("created_at")
  updatedAt    DateTime @updatedAt @map("updated_at")

  @@map("users")
  @@index([email])
}

Generate Prisma client saat install:

# Tambahkan ke postinstall script
# "postinstall": "prisma generate"

# Atau jalankan manual
docker compose exec api npx prisma generate

db/init/01-init.sql — initial SQL (opsional, dijalankan saat Postgres pertama start):

-- Buat extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Bisa juga pre-create schema
-- CREATE SCHEMA IF NOT EXISTS app;
Untuk synchronize: false di Prisma production, jalankan migration manual: docker compose exec api npx prisma migrate deploy. Untuk development, Prisma bisa sinkronkan schema otomatis saat prisma generate. Tapi lebih baik pakai migration file dari awal, supaya history schema tercatat.

Testing dengan Fastify.inject() #

Salah satu fitur paling powerful Fastify adalah app.inject() — test bisa kirim HTTP request langsung ke aplikasi tanpa start server di port. Lebih cepat dan lebih reliable dibanding supertest + listen di port ephemeral.

test/users.test.ts:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { build } from '../src/server.js';

let app: any;

beforeAll(async () => {
  app = await build();
  await app.ready();
});

afterAll(async () => {
  await app.close();
});

describe('GET /api/v1/users', () => {
  it('mengembalikan list users', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/api/v1/users',
    });

    expect(response.statusCode).toBe(200);
    const body = response.json();
    expect(body).toHaveProperty('data');
    expect(Array.isArray(body.data)).toBe(true);
  });

  it('menolak request tanpa auth', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/api/v1/users/me',
    });
    expect(response.statusCode).toBe(401);
  });
});

describe('POST /api/v1/users', () => {
  it('menolak body tanpa email valid', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/v1/users',
      payload: { name: 'Test', password: 'short' },
    });
    expect(response.statusCode).toBe(400);
  });
});

app.inject() jauh lebih cepat dari supertest karena tidak ada overhead HTTP, socket, atau port binding. Test bisa berjalan paralel tanpa konflik port.


Build dan Run #

# Build image pertama kali
docker compose build

# Jalankan semua service
docker compose up -d

# Lihat log
docker compose logs -f api

# Prisma generate (setelah ubah schema)
docker compose exec api npx prisma generate

# Prisma migrate
docker compose exec api npx prisma migrate dev

# Buka Prisma Studio
open http://localhost:5555

# Akses Swagger UI
open http://localhost:3000/docs

# Stop
docker compose down

Akses:

Service URL
API http://localhost:3000
Swagger UI http://localhost:3000/docs
Health check http://localhost:3000/health
Prisma Studio http://localhost:5555
Postgres localhost:5432 (user app, password dev)
Redis localhost:6379
Swagger UI di /docs adalah bonus Fastify + @fastify/swagger + @fastify/swagger-ui. Cukup tambahkan schema di setiap route, dan dokumentasi OpenAPI 3.0 muncul otomatis. Sangat berguna untuk frontend developer atau QA yang butuh test tanpa bertanya ke backend developer.

Best Practice #

1. Selalu Definisikan Schema #

// ANTI-PATTERN: tanpa schema
fastify.post('/users', async (request) => {
  const { email } = request.body;  // request.body adalah any!
  // ...
});

// BENAR: dengan schema
fastify.post('/users', {
  schema: {
    body: { type: 'object', properties: { email: { type: 'string', format: 'email' } } },
    response: { 201: userSchema },
  },
}, async (request) => {
  const { email } = request.body;  // typed dan validated
});

Schema bukan opsional — itu yang membuat Fastify “fast” dan “secure”.

2. Pakai fastify-plugin untuk Infrastructure Plugin #

Plugin yang di-decorate (Prisma, Redis, JWT) harus pakai fp() agar decorator tersedia di scope atas. Plugin untuk routing tidak perlu fp() — biarkan encapsulated.

3. Pisahkan Build dan Listen #

Pola app() + server.ts:

  • app() — return instance Fastify siap pakai (untuk test)
  • server.ts — panggil app() lalu listen()

Test bisa inject() tanpa start server. Production bisa listen() di host yang benar.

4. Error Handling Terpusat #

setErrorHandler di app.ts adalah single source of truth untuk response error. Custom error classes (NotFoundError, ValidationError) membuat throw + handle jadi eksplisit dan konsisten.

5. Logging dengan Pino #

Fastify default pakai Pino — salah satu logger tercepat untuk Node.js. Di development, pino-pretty untuk output berwarna. Di production, log JSON ke stdout (untuk di-aggregate ke ELK, Datadog, dll).

6. Schema-First dengan Zod untuk Tim TypeScript #

Untuk tim TypeScript, fastify-type-provider-zod memberi type safety penuh dari Zod schema ke handler. Editor auto-complete request.body dari schema.

7. Health Check untuk Container #

fastify.get('/health', async () => {
  try {
    await fastify.prisma.$queryRaw`SELECT 1`;
    await fastify.redis.ping();
    return { status: 'ok', uptime: process.uptime() };
  } catch (err) {
    throw new Error('Database or cache unavailable');
  }
});

8. JSON Schema Reuse #

// schemas/common.ts
export const idParam = {
  type: 'object',
  properties: { id: { type: 'integer', minimum: 1 } },
  required: ['id'],
} as const;

export const paginationQuery = {
  type: 'object',
  properties: {
    limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
    offset: { type: 'integer', minimum: 0, default: 0 },
  },
} as const;

Reuse di banyak route mengurangi duplikasi.


Kapan Fastify, Kapan yang Lain? #

Framework Performa Struktur Tipe Validasi Cocok Untuk
Fastify Sangat tinggi Modular, plugin JSON Schema / Zod API performa tinggi
Express Standar Fleksibel Manual / library API sederhana, prototype
NestJS Tinggi Opinionated class-validator Enterprise, monorepo
Koa Tinggi Minimal Manual Middleware custom
Hapi Standar Plugin-centric Joi (built-in) Aplikasi besar konvensional
Fastify ideal untuk public API yang menerima banyak request dan butuh validasi otomatis. Untuk aplikasi internal dengan traffic rendah, Express atau Hapi sudah lebih dari cukup. Untuk monorepo dengan 10+ service, NestJS memberi konsistensi yang Fastify tidak tawarkan secara default.

Troubleshooting #

Hot Reload Tidak Restart #

Cek command: npm run dev di docker-compose.yml. Pastikan script dev di package.json adalah tsx watch src/server.ts. Cek juga log: docker compose logs -f apitsx akan print “restarting” saat ada perubahan.

Schema Validation Reject Semua Request #

Pastikan additionalProperties: false di schema body tidak membuat field yang sebelumnya valid menjadi invalid. Cek juga required array — kalau kamu tandai field sebagai required, request tanpa field tersebut ditolak.

Prisma Tidak Bisa Connect #

Error: P1001: Can't reach database server

Hostname di DATABASE_URL harus db (nama service Compose), bukan localhost. Cek juga: docker compose exec api env | grep DATABASE.

Decorator Tidak Tersedia #

request.user, fastify.prisma, dll — pastikan plugin di-decorate dengan fastify.decorate() dan di-register di scope yang tepat. Untuk global decorator, pakai fastify-plugin (fp()).

Port Sudah Dipakai #

# Cari proses yang pakai port 3000
lsof -i :3000

# Stop proses
kill -9 <PID>

# Atau ubah port di docker-compose.yml
ports:
  - "3001:3000"

Ringkasan #

  • Fastify ideal untuk local development API yang butuh performa tinggi dan validasi otomatis. Hot reload via tsx watch membuat iterasi TypeScript cepat tanpa keluar dari container.
  • Dockerfile multi-stage: stage deps untuk development (tsx, typescript, prisma), stage builder untuk compile, stage runner ramping untuk production dengan non-root user.
  • Anonymous volume /app/node_modules dan /app/dist wajib — melindungi binary module dan build output dari override host.
  • Pemisahan server.ts (factory) + app.ts (build) memudahkan testing. Test bisa inject() request langsung tanpa listen di port.
  • Schema-first philosophy — JSON Schema atau Zod untuk validasi body, params, querystring. Bonus: dokumentasi OpenAPI otomatis via @fastify/swagger.
  • Plugin system dengan fastify.register() membuat encapsulated context. Pakai fastify-plugin (fp()) untuk infrastructure plugin (Prisma, Redis) agar decorator tersedia global.
  • Hooks seperti onRequest, preHandler, onClose — middleware-nya Fastify. Lebih terstruktur dan lebih cepat dari middleware Express.
  • Error handling terpusat dengan setErrorHandler. Custom error classes (NotFoundError, ValidationError) membuat throw + handle jadi eksplisit.
  • Prisma ORM + plugin pattern. Type generation otomatis, query SQL aman, migration tool bawaan.
  • Swagger UI gratis di /docs — dokumentasi OpenAPI auto-generated dari schema.
  • Testing dengan app.inject() — request HTTP langsung ke aplikasi tanpa listen di port, lebih cepat dan reliable dari supertest.
  • Pino logger built-in, salah satu yang tercepat di Node.js. pino-pretty untuk development, JSON log untuk production.
  • Kapan pilih Fastify: API performa tinggi, validasi schema, butuh OpenAPI. Kapan pilih Express: prototype, API gateway ringan. Kapan pilih NestJS: monorepo enterprise.
  • Troubleshooting: hot reload (cek script + log), schema reject (cek additionalProperties), Prisma (hostname service), decorator (fp + scope), port conflict (lsof).
  • Best practice: selalu definisikan schema, reuse JSON Schema, pisahkan build/listen, error handler terpusat, schema-first dengan Zod untuk TS, health check untuk container, non-root user di production.

← Sebelumnya: NestJS   Berikutnya: Next.js →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact