Node.js & TypeScript Production Engineering
Complete methodology for building production-grade Node.js backends with TypeScript. Covers architecture, frameworks, error handling, database patterns, security, testing, observability, and deployment.
Quick Health Check (/16)
Run through these 8 signals — score 0 (missing) or 2 (present):
| # | Signal | Check |
|---|
| 1 | Strict TypeScript | "strict": true in tsconfig, no any escape hatches |
| 2 | Structured errors | Custom error classes with codes, not string throws |
| 3 | Input validation | Zod/Valibot on every external boundary |
| 4 | Database migrations | Version-controlled, reversible, CI-enforced |
| 5 | Health endpoints | /health (liveness) + /ready (readiness) with dependency checks |
| 6 | Structured logging | JSON logs with request IDs, no console.log in prod |
| 7 | Test coverage | >80% unit, integration tests for critical paths |
| 8 | Graceful shutdown | SIGTERM handler drains connections before exit |
Score interpretation: 0-6 = critical gaps, 8-10 = needs work, 12-14 = solid, 16 = production-grade.
Phase 1: Architecture & Project Structure
Framework Selection Matrix
| Framework | Best For | Throughput | Ecosystem | Learning Curve | TypeScript |
|---|
| Hono | Edge/serverless, lightweight APIs | ⭐⭐⭐⭐⭐ | Growing fast | Low | Native |
| Fastify | High-perf monoliths, JSON APIs | ⭐⭐⭐⭐⭐ | Mature | Medium | Excellent |
| Express | Legacy, max middleware ecosystem | ⭐⭐⭐ | Massive | Low | Via @types |
| NestJS | Enterprise, large teams, DI-heavy | ⭐⭐⭐⭐ | Large | High | Native |
| Elysia | Bun-first, type-safe APIs | ⭐⭐⭐⭐⭐ | Small | Low | Native |
| tRPC | Full-stack TS, type-safe RPC | N/A (layer) | Growing | Medium | Native |
Decision rules:
- New project, small team → Hono (portable, fast, minimal)
- High throughput JSON API → Fastify (proven, benchmarked)
- Enterprise, 10+ developers → NestJS (structure enforced)
- Full-stack TypeScript monorepo → tRPC (end-to-end types)
- Existing Express codebase → Stay on Express, migrate incrementally
- Bun runtime → Elysia or Hono
Recommended Project Structure
src/
├── index.ts # Entry point — bootstrap only
├── app.ts # App factory (createApp)
├── config/
│ ├── env.ts # Environment validation (Zod)
│ └── database.ts # DB connection config
├── routes/
│ ├── index.ts # Route registry
│ ├── users.ts # /users routes
│ └── orders.ts # /orders routes
├── services/
│ ├── user.service.ts # Business logic
│ └── order.service.ts
├── repositories/
│ ├── user.repo.ts # Data access (Drizzle/Prisma)
│ └── order.repo.ts
├── middleware/
│ ├── auth.ts # Authentication
│ ├── validate.ts # Request validation
│ ├── error-handler.ts # Global error handler
│ └── request-id.ts # Correlation ID
├── errors/
│ └── index.ts # Custom error classes
├── types/
│ └── index.ts # Shared types
├── utils/
│ └── index.ts # Pure utility functions
├── jobs/ # Background jobs/queues
│ └── email.job.ts
└── __tests__/ # Tests mirror src/ structure
├── services/
└── routes/
drizzle/ # Database migrations
├── 0001_create_users.sql
└── meta/
tsconfig.json
package.json
Dockerfile
docker-compose.yml
.env.example
7 Architecture Rules
- Routes → Services → Repositories — never skip layers
- Services contain business logic — routes are thin (validate + call service + respond)
- Repositories own data access — services never import DB client directly
- No circular dependencies — dependency flow is strictly downward
- One export per file — makes imports predictable, testing easy
- Config validated at startup — fail fast, not at runtime
- ≤50 lines per function, ≤300 lines per file — split when exceeded
Phase 2: TypeScript Configuration
Production tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
8 TypeScript Rules
- Never use
any — use unknown and narrow, or define proper types
noUncheckedIndexedAccess: true — forces null checks on array/object access
- Branded types for IDs —
type UserId = string & { __brand: 'UserId' }
- Zod schemas derive types —
type User = z.infer<typeof UserSchema>
- Discriminated unions for states —
{ status: 'active'; data: T } | { status: 'error'; error: E }
satisfies over as — preserves narrowed types: config satisfies Config
- Enums → const objects —
const Status = { ACTIVE: 'active', INACTIVE: 'inactive' } as const
- Return types on public APIs — explicit return types on exported functions
Branded Types Pattern
// types/branded.ts
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { [__brand]: B };
export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type Email = Brand<string, 'Email'>;
export function UserId(id: string): UserId { return id as UserId; }
export function Email(email: string): Email {
if (!email.includes('@')) throw new ValidationError('Invalid email');
return email as Email;
}
Phase 3: Environment & Configuration
Validated Configuration with Zod
// config/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('15m'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
CORS_ORIGINS: z.string().transform(s => s.split(',')).default('*'),
RATE_LIMIT_MAX: z.coerce.number().default(100),
RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60_000),
});
export type Env = z.infer<typeof EnvSchema>;
// Validate at import time — fail fast
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
5 Configuration Rules
- Validate ALL env vars at startup — never read
process.env directly elsewhere
- Type-safe access only — import
env object, not process.env
- Secrets via vault/env — never hardcoded, never committed
.env.example always current — document every var with description + default
- Different configs per environment — use
NODE_ENV for branching, never feature flags in env
Phase 4: Error Handling Architecture
Custom Error Hierarchy
// errors/index.ts
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'VALIDATION_ERROR', 400, details);
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'NOT_FOUND', 404, { resource, id });
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 'UNAUTHORIZED', 401);
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Insufficient permissions') {
super(message, 'FORBIDDEN', 403);
}
}
export class ConflictError extends AppError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'CONFLICT', 409, details);
}
}
export class RateLimitError extends AppError {
constructor(retryAfterMs: number) {
super('Too many requests', 'RATE_LIMITED', 429, { retryAfterMs });
}
}
Global Error Handler
// middleware/error-handler.ts
import type { ErrorHandler } from 'hono';
import { AppError } from '../errors/index.js';
import { logger } from '../utils/logger.js';
export const errorHandler: ErrorHandler = (err, c) => {
const requestId = c.get('requestId');
if (err instanceof AppError) {
if (err.statusCode >= 500) {
logger.error({ err, requestId }, 'Server error');
} else {
logger.warn({ err, requestId }, 'Client error');
}
return c.json({
error: { code: err.code, message: err.message, details: err.details },
}, err.statusCode as any);
}
// Unexpected errors — don't leak internals
logger.error({ err, requestId }, 'Unhandled error');
return c.json({
error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
}, 500);
};
6 Error Handling Rules
- Throw custom errors, catch at boundary — services throw, routes/middleware catch
- Never throw strings — always
throw new SomeError(message)
- Don't leak internals — 5xx returns generic message, log the real error
- Include error codes — machine-readable codes for client handling
- Validate early, fail fast — Zod at route level before business logic
- Async errors auto-caught — use frameworks that handle rejected promises (Hono/Fastify do this natively)
Phase 5: Input Validation
Zod Schema Patterns
// routes/users.ts
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const CreateUserSchema = z.object({
email: z.string().email().max(255).toLowerCase(),
name: z.string().min(1).max(100).trim(),
role: z.enum(['user', 'admin']).default('user'),
});
const PaginationSchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
const UserIdParamSchema = z.object({
id: z.string().uuid(),
});
// Usage with Hono
app.post('/users', zValidator('json', CreateUserSchema), async (c) => {
const body = c.req.valid('json'); // Fully typed!
const user = await userService.create(body);
return c.json({ data: user }, 201);
});
app.get('/users', zValidator('query', PaginationSchema), async (c) => {
const { cursor, limit } = c.req.valid('query');
const result = await userService.list({ cursor, limit });
return c.json({ data: result.items, nextCursor: result.nextCursor });
});
Validation Rules
- Validate at the edge — every route handler validates input before calling services
- Schemas define the contract — derive TypeScript types from Zod, not the other way around
- Transform in schemas —
.trim(), .toLowerCase(), .default() in Zod, not in services
- Reuse common schemas —
PaginationSchema, DateRangeSchema, SortSchema
- Validate path params too — UUIDs, slugs, numeric IDs
Phase 6: Database Patterns
ORM Selection Guide
| ORM | Best For | Type Safety | Migration | Query Builder | Learning Curve |
|---|
| Drizzle | SQL-first, edge, performance | ⭐⭐⭐⭐⭐ | Built-in | SQL-like | Low |
| Prisma | Rapid dev, schema-first | ⭐⭐⭐⭐ | Built-in | Custom DSL | Low |
| Kysely | SQL purists, complex queries | ⭐⭐⭐⭐⭐ | External | Type-safe SQL | Medium |
| TypeORM | Legacy, decorator-style | ⭐⭐⭐ | Built-in | ORM-style | Medium |
Decision: Drizzle for new projects (matches SQL mental model, best TypeScript inference, edge-compatible).
Drizzle Schema Example
// drizzle/schema.ts
import { pgTable, text, timestamp, uuid, varchar, boolean, index } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 100 }).notNull(),
role: text('role', { enum: ['user', 'admin'] }).notNull().default('user'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_users_email').on(table.email),
index('idx_users_created').on(table.createdAt),
]);
Repository Pattern
// repositories/user.repo.ts
import { eq, desc, gt } from 'drizzle-orm';
import { db } from '../config/database.js';
import { users } from '../../drizzle/schema.js';
import type { UserId } from '../types/branded.js';
import { NotFoundError } from '../errors/index.js';
export class UserRepository {
async findById(id: UserId) {
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!user) throw new NotFoundError('User', id);
return user;
}
async list(opts: { cursor?: string; limit: number }) {
const query = db.select().from(users).orderBy(desc(users.createdAt)).limit(opts.limit + 1);
if (opts.cursor) query.where(gt(users.id, opts.cursor));
const rows = await query;
const hasMore = rows.length > opts.limit;
return { items: rows.slice(0, opts.limit), nextCursor: hasMore ? rows[opts.limit - 1].id : undefined };
}
async create(data: { email: string; name: string; role?: string }) {
const [user] = await db.insert(users).values(data).returning();
return user;
}
async update(id: UserId, data: Partial<{ name: string; role: string; isActive: boolean }>) {
const [user] = await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, id)).returning();
if (!user) throw new NotFoundError('User', id);
return user;
}
}
Database Rules
- Cursor-based pagination — never OFFSET for user-facing lists
- Migrations in version control —
drizzle-kit generate → commit → drizzle-kit migrate in CI
- Connection pooling — use
pg pool (min: 2, max: 10 per process) or serverless driver for edge
- Transactions for multi-write —
db.transaction(async (tx) => { ... })
- Index query patterns — add indexes for WHERE + ORDER BY columns, monitor slow queries
- SQLite for dev, Postgres for prod — Drizzle supports both with same schema (use
better-sqlite3 locally)
Phase 7: Authentication & Authorization
JWT Auth Middleware (Hono)
// middleware/auth.ts
import { jwt } from 'hono/jwt';
import { env } from '../config/env.js';
import type { UserId } from '../types/branded.js';
type JwtPayload = { sub: UserId; role: 'user' | 'admin'; iat: number; exp: number };
export const authenticate = jwt({ secret: env.JWT_SECRET });
export const requireRole = (...roles: string[]) => {
return async (c: any, next: any) => {
const payload = c.get('jwtPayload') as JwtPayload;
if (!roles.includes(payload.role)) {
throw new ForbiddenError(`Required role: ${roles.join(' or ')}`);
}
await next();
};
};
Security Checklist
| # | Item | Priority |
|---|
| 1 | Helmet/security headers (CSP, HSTS, X-Frame) | P0 |
| 2 | Rate limiting per IP + per user | P0 |
| 3 | Input validation on every endpoint | P0 |
| 4 | CORS configured (not * in prod) | P0 |
| 5 | JWT short-lived (15m) + refresh token rotation | P0 |
| 6 | Password hashing (argon2id, cost ≥ 3) | P0 |
| 7 | SQL injection prevention (parameterized queries) | P0 |
| 8 | Request size limits (1MB default) | P1 |
| 9 | Dependency audit (npm audit, Snyk) | P1 |
| 10 | API key scoping (read-only, write, admin) | P1 |
Phase 8: Structured Logging & Observability
Pino Logger Setup
// utils/logger.ts
import pino from 'pino';
import { env } from '../config/env.js';
export const logger = pino({
level: env.LOG_LEVEL,
...(env.NODE_ENV === 'development' && { transport: { target: 'pino-pretty' } }),
serializers: { err: pino.stdSerializers.err },
redact: ['req.headers.authorization', '*.password', '*.token', '*.secret'],
formatters: {
level: (label) => ({ level: label }),
},
});
Request ID Middleware
// middleware/request-id.ts
import { randomUUID } from 'node:crypto';
export const requestId = () => {
return async (c: any, next: any) => {
const id = c.req.header('x-request-id') || randomUUID();
c.set('requestId', id);
c.header('x-request-id', id);
await next();
};
};
Request Logging Middleware
// middleware/request-logger.ts
import { logger } from '../utils/logger.js';
export const requestLogger = () => {
return async (c: any, next: any) => {
const start = performance.now();
const requestId = c.get('requestId');
await next();
const duration = Math.round(performance.now() - start);
const level = c.res.status >= 500 ? 'error' : c.res.status >= 400 ? 'warn' : 'info';
logger[level]({
requestId,
method: c.req.method,
path: c.req.path,
status: c.res.status,
durationMs: duration,
userAgent: c.req.header('user-agent'),
}, `${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`);
};
};
Health Endpoints
// routes/health.ts
app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
app.get('/ready', async (c) => {
const checks = {
database: false,
redis: false,
};
try {
await db.execute(sql`SELECT 1`);
checks.database = true;
} catch {}
try {
await redis.ping();
checks.redis = true;
} catch {}
const ready = Object.values(checks).every(Boolean);
return c.json({ status: ready ? 'ready' : 'degraded', checks }, ready ? 200 : 503);
});
Phase 9: Testing Strategy
Test Pyramid
| Level | Coverage Target | Tools | What to Test |
|---|
| Unit | >80% | Vitest | Services, utils, pure logic |
| Integration | Critical paths | Vitest + testcontainers | Routes → DB round-trip |
| E2E | Happy paths | Vitest + supertest | Full HTTP request cycle |
| Contract | API boundaries | Vitest | Request/response shapes |
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: { provider: 'v8', reporter: ['text', 'lcov'], thresholds: { lines: 80, branches: 75 } },
setupFiles: ['./src/__tests__/setup.ts'],
pool: 'forks', // Isolation for DB tests
},
});
Service Unit Test Pattern
// __tests__/services/user.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from '../../services/user.service.js';
import type { UserRepository } from '../../repositories/user.repo.js';
describe('UserService', () => {
let service: UserService;
let repo: jest.Mocked<UserRepository>;
beforeEach(() => {
repo = { findById: vi.fn(), create: vi.fn(), update: vi.fn(), list: vi.fn() } as any;
service = new UserService(repo);
});
it('creates user with normalized email', async () => {
repo.create.mockResolvedValue({ id: '1', email: 'test@example.com', name: 'Test' } as any);
const result = await service.create({ email: 'Test@Example.COM', name: 'Test' });
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'test@example.com' }));
expect(result.email).toBe('test@example.com');
});
});
Integration Test Pattern
// __tests__/routes/users.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createApp } from '../../app.js';
import { testDb, migrate, cleanup } from '../helpers/db.js';
describe('POST /users', () => {
let app: any;
beforeAll(async () => {
await migrate(testDb);
app = createApp({ db: testDb });
});
afterAll(async () => { await cleanup(testDb); });
it('creates user and returns 201', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'new@example.com', name: 'New User' }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.email).toBe('new@example.com');
});
it('rejects invalid email with 400', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'not-an-email', name: 'Bad' }),
});
expect(res.status).toBe(400);
});
});
7 Testing Rules
- Test behavior, not implementation — assert outputs, not internal method calls
- Each test is independent — no shared mutable state between tests
- Name tests as specifications —
it('rejects expired JWT with 401')
- Fast unit tests, isolated integration tests — use
pool: 'forks' for DB tests
- Mock at boundaries — mock repositories in service tests, real DB in integration tests
- Test error paths — 400s, 404s, 409s, 500s, not just happy paths
- CI enforces coverage — fail build if coverage drops below threshold
Phase 10: Graceful Shutdown & Process Management
Shutdown Handler
// index.ts
import { serve } from '@hono/node-server';
import { createApp } from './app.js';
import { logger } from './utils/logger.js';
import { env } from './config/env.js';
import { db } from './config/database.js';
const app = createApp();
const server = serve({ fetch: app.fetch, port: env.PORT });
logger.info({ port: env.PORT }, 'Server started');
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
// Stop accepting new connections
server.close(() => { logger.info('HTTP server closed'); });
// Drain existing work (give 10s)
const timeout = setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10_000);
try {
// Close DB pool, Redis, queues, etc.
await db.$client.end();
logger.info('Database pool closed');
clearTimeout(timeout);
process.exit(0);
} catch (err) {
logger.error({ err }, 'Error during shutdown');
process.exit(1);
}
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Catch unhandled errors
process.on('unhandledRejection', (err) => {
logger.fatal({ err }, 'Unhandled rejection');
process.exit(1);
});
Phase 11: Production Deployment
Multi-Stage Dockerfile
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY tsconfig.json ./
COPY src/ src/
COPY drizzle/ drizzle/
RUN npm run build
RUN npm ci --omit=dev --ignore-scripts
# Production stage
FROM node:22-alpine
RUN apk add --no-cache tini dumb-init
WORKDIR /app
COPY --from=builder /app/dist dist/
COPY --from=builder /app/drizzle drizzle/
COPY --from=builder /app/node_modules node_modules/
COPY --from=builder /app/package.json .
ENV NODE_ENV=production
USER node
EXPOSE 3000
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/index.js"]
GitHub Actions CI
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: { POSTGRES_DB: test, POSTGRES_PASSWORD: test }
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --coverage
env: { DATABASE_URL: 'postgresql://postgres:test@localhost:5432/test' }
- run: npm run build
Production Checklist
P0 — Mandatory:
P1 — Recommended:
Phase 12: Performance Optimization
Priority Stack
| # | Technique | Impact | Effort |
|---|
| 1 | Connection pooling (DB + Redis) | High | Low |
| 2 | Response caching (Redis/in-memory) | High | Medium |
| 3 | Query optimization (indexes, N+1) | High | Medium |
| 4 | JSON serialization (fast-json-stringify) | Medium | Low |
| 5 | Compression middleware | Medium | Low |
| 6 | Streaming responses for large payloads | Medium | Medium |
| 7 | Worker threads for CPU-bound work | High | High |
| 8 | HTTP/2 + keep-alive | Low | Low |
Common Optimizations
// N+1 prevention — batch with dataloader or SQL JOIN
// ❌ Bad: for loop with individual queries
for (const order of orders) {
order.user = await db.query.users.findFirst({ where: eq(users.id, order.userId) });
}
// ✅ Good: single query with join
const ordersWithUsers = await db
.select()
.from(orders)
.leftJoin(users, eq(orders.userId, users.id))
.where(inArray(orders.id, orderIds));
// In-memory caching for hot data
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, any>({ max: 1000, ttl: 60_000 });
async function getCachedUser(id: string) {
const cached = cache.get(id);
if (cached) return cached;
const user = await userRepo.findById(id);
cache.set(id, user);
return user;
}
Phase 13: Background Jobs & Queues
BullMQ Pattern
// jobs/email.job.ts
import { Queue, Worker } from 'bullmq';
import { env } from '../config/env.js';
import { logger } from '../utils/logger.js';
const connection = { url: env.REDIS_URL };
export const emailQueue = new Queue('email', { connection });
export const emailWorker = new Worker('email', async (job) => {
const { to, subject, body } = job.data;
logger.info({ jobId: job.id, to }, 'Sending email');
// Send via provider
await sendEmail({ to, subject, body });
}, {
connection,
concurrency: 5,
limiter: { max: 10, duration: 1000 }, // 10 per second
});
emailWorker.on('failed', (job, err) => {
logger.error({ jobId: job?.id, err }, 'Email job failed');
});
Job Rules
- Idempotent jobs — same job ID running twice produces same result
- Retry with backoff — exponential backoff for transient failures
- Dead letter queue — failed jobs after max retries go to DLQ for inspection
- Job timeout — set
removeOnComplete and removeOnFail TTLs
- Monitor queue health — queue length, processing time, failure rate
Phase 14: Advanced Patterns
Dependency Injection (Manual)
// app.ts — compose dependencies explicitly
export function createApp(deps?: { db?: Database; redis?: Redis }) {
const database = deps?.db ?? defaultDb;
const app = new Hono();
// Compose service graph
const userRepo = new UserRepository(database);
const userService = new UserService(userRepo);
// Mount routes with injected services
app.route('/users', createUserRoutes(userService));
return app;
}
Rate Limiting
// middleware/rate-limit.ts (using hono-rate-limiter)
import { rateLimiter } from 'hono-rate-limiter';
export const apiRateLimit = rateLimiter({
windowMs: 60_000,
limit: 100,
keyGenerator: (c) => c.get('jwtPayload')?.sub || c.req.header('x-forwarded-for') || 'anonymous',
message: { error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
});
WebSocket Pattern (Hono + @hono/node-ws)
import { createNodeWebSocket } from '@hono/node-ws';
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
app.get('/ws', upgradeWebSocket((c) => ({
onOpen(evt, ws) { logger.info('WebSocket connected'); },
onMessage(evt, ws) {
const data = JSON.parse(evt.data.toString());
// Handle message
ws.send(JSON.stringify({ type: 'ack', id: data.id }));
},
onClose() { logger.info('WebSocket disconnected'); },
})));
2025+ Recommended Stack
| Layer | Recommended | Alternative |
|---|
| Runtime | Node.js 22 LTS | Bun |
| Framework | Hono | Fastify |
| Language | TypeScript (strict) | — |
| Validation | Zod | Valibot |
| ORM | Drizzle | Prisma |
| Database | PostgreSQL | SQLite (dev) |
| Cache | Redis / Upstash | LRU in-memory |
| Auth | JWT + refresh | Lucia, Better Auth |
| Queue | BullMQ | pg-boss |
| Testing | Vitest | — |
| Logging | Pino | — |
| Linting | Biome | ESLint + Prettier |
| CI/CD | GitHub Actions | — |
| Deploy | Docker + Railway/Fly | Vercel (serverless) |
10 Commandments
- Validate everything at the boundary — Zod on every route
- Fail fast at startup — env vars, DB connection, migrations
- Structured logging everywhere — JSON, request IDs, no console.log
- Custom errors with codes — never throw strings
- Cursor pagination — never OFFSET
- Graceful shutdown — drain connections on SIGTERM
- Test error paths — 4xx/5xx matter more than happy paths
- Type everything — no
any, branded IDs, Zod-derived types
- Security by default — rate limit, helmet, CORS, audit deps
- Keep it simple — 50 lines/function, 300 lines/file, flat is better than nested
10 Common Mistakes
| # | Mistake | Fix |
|---|
| 1 | console.log in production | Use Pino with JSON output |
| 2 | any type everywhere | unknown + type narrowing |
| 3 | No input validation | Zod middleware on every route |
| 4 | OFFSET pagination | Cursor-based with keyset |
| 5 | No graceful shutdown | SIGTERM handler with drain timeout |
| 6 | Hardcoded config | Zod-validated env at startup |
| 7 | Fat controllers | Thin routes → services → repositories |
| 8 | No error hierarchy | Custom AppError with codes |
| 9 | Testing only happy paths | Test 400/401/404/409/500 explicitly |
| 10 | No request tracing | Request ID middleware + propagation |
Quality Scoring (0-100)
| Dimension | Weight | What to Assess |
|---|
| Type safety | 20% | Strict TS, no any, branded types |
| Error handling | 15% | Custom errors, global handler, no leaks |
| Testing | 15% | Coverage >80%, integration tests, error paths |
| Security | 15% | Auth, validation, rate limiting, headers |
| Observability | 10% | Structured logging, health checks, metrics |
| Performance | 10% | Connection pooling, caching, N+1 prevention |
| Code structure | 10% | Layer separation, file size limits, DI |
| Deployment | 5% | Docker, CI/CD, graceful shutdown |
Edge Cases
Startup/MVP
- Start with Hono + SQLite (Drizzle) + Vitest — ship in hours
- Add Postgres when you need concurrent writes
- Skip Redis until you need caching or queues
Monorepo (Turborepo/Nx)
- Shared packages:
@org/types, @org/db, @org/validation
- Each service is its own package with independent build/test
- Use workspace protocol:
"@org/types": "workspace:*"
Serverless (Vercel/Cloudflare Workers)
- Hono runs everywhere — same code, different adapters
- Use serverless DB drivers (Neon, PlanetScale, Turso)
- No graceful shutdown needed — stateless by design
Legacy Express Migration
- Add TypeScript incrementally (
.ts files alongside .js)
- Wrap Express routes with Zod validation middleware
- Replace
console.log with Pino one file at a time
- Migrate to Hono/Fastify route-by-route using adapter
Natural Language Commands
- "Set up a new TypeScript API project" → Phase 1-3 (structure + config + env)
- "Add authentication to my API" → Phase 7 (JWT + RBAC)
- "Review my error handling" → Phase 4 (error hierarchy + handler)
- "Add database with Drizzle" → Phase 6 (schema + repository + migrations)
- "Set up testing" → Phase 9 (Vitest + unit + integration patterns)
- "Prepare for production deployment" → Phase 11 (Docker + CI + checklist)
- "Optimize API performance" → Phase 12 (priority stack + patterns)
- "Add background jobs" → Phase 13 (BullMQ + patterns)
- "Audit my API security" → Phase 7 security checklist
- "Set up logging and monitoring" → Phase 8 (Pino + request ID + health)
- "Review my project structure" → Phase 1 (structure + rules)
- "Full health check" → Quick Health Check + Quality Scoring
⚡ Built by AfrexAI — AI-powered business automation.