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
Polaserver.ts+app.tsadalah best practice Fastify.server.tsadalah factory yang membuat instance Fastify dengan konfigurasi lengkap.app.tsadalah entry point yanglisten. Pola ini memudahkan testing: test bisabuildaplikasi tanpalisten, laluinject()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.
Imagenode:20-alpinesudah termasuk glibc yang kompatibel dengan binary module sepertibcrypt,sharp, danprisma. Untuk module yang sangat platform-specific, kadang perlunode: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 dariapp.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 |
GunakanadditionalProperties: falsedi 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. Tanpafp, decoratorprismadanredishanya tersedia di scope plugin. Denganfp, decorator tersedia di scope atas (di mana plugin di-register). Untuk plugin infrastructure seperti database dan cache, selalu pakaifp.
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;
Untuksynchronize: falsedi Prisma production, jalankan migration manual:docker compose exec api npx prisma migrate deploy. Untuk development, Prisma bisa sinkronkan schema otomatis saatprisma 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/docsadalah 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— panggilapp()lalulisten()
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 api — tsx 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 watchmembuat iterasi TypeScript cepat tanpa keluar dari container.- Dockerfile multi-stage: stage
depsuntuk development (tsx, typescript, prisma), stagebuilderuntuk compile, stagerunnerramping untuk production dengan non-root user.- Anonymous volume
/app/node_modulesdan/app/distwajib — melindungi binary module dan build output dari override host.- Pemisahan
server.ts(factory) +app.ts(build) memudahkan testing. Test bisainject()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. Pakaifastify-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-prettyuntuk 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.