Files
gh-madappgang-claude-code-p…/skills/best-practices.md
2025-11-30 08:38:52 +08:00

36 KiB

TypeScript Backend Best Practices with Bun (2025 Edition)

This skill provides production-ready best practices for building TypeScript backend applications using Bun runtime. Use this guidance when implementing features, reviewing code, or making architectural decisions. Updated November 2025 with the latest tool versions and patterns.

Why Bun

Bun fundamentally transforms TypeScript backend development by:

  • Native TypeScript execution - No build steps in development
  • Lightning-fast performance - 3-4x faster than Node.js for many operations
  • Unified toolkit - Built-in test runner, bundler, and transpiler
  • Drop-in compatibility - Most Node.js APIs and npm packages work out of the box
  • Developer experience - Hot reload with --hot, instant feedback

Stack Overview

  • Bun 1.x (runtime, package manager, test runner, bundler)
  • TypeScript 5.7 (strict mode)
  • Hono 4.6 (ultra-fast web framework, TypeScript-first)
  • Prisma 6.2 (type-safe ORM)
  • Biome 2.3 (formatting + linting, replaces ESLint + Prettier)
  • Zod (runtime validation)
  • Pino (structured logging)
  • PostgreSQL 17 (database)
  • Redis (caching)
  • Docker (containerization)
  • AWS ECS (deployment)

Project Structure

project-root/
├── src/
│   ├── server.ts              # Entry point (starts server)
│   ├── app.ts                 # Hono app initialization & middleware
│   ├── config.ts              # Environment configuration
│   ├── core/                  # Core utilities (errors, logger, responses)
│   ├── database/
│   │   ├── client.ts          # Prisma client setup
│   │   └── repositories/      # Data access layer (Prisma queries)
│   ├── services/              # Business logic layer
│   ├── controllers/           # HTTP handlers (calls services)
│   ├── middleware/            # Hono middleware (auth, validation, etc.)
│   ├── routes/                # API route definitions
│   ├── schemas/               # Zod validation schemas
│   ├── types/                 # TypeScript type definitions
│   └── utils/                 # Utility functions
├── tests/
│   ├── unit/                  # Unit tests
│   ├── integration/           # Integration tests (API + DB)
│   └── e2e/                   # End-to-end tests
├── prisma/                    # Prisma schema & migrations
├── .github/workflows/         # CI/CD pipelines
├── Dockerfile                 # Production container
├── docker-compose.yml         # Local dev environment
├── tsconfig.json              # TypeScript config
├── biome.json                 # Biome config
├── package.json               # Bun-managed dependencies
└── bun.lockb                  # Bun lockfile

Key Principles:

  • Structure by technical capability, not by feature
  • Each layer has single responsibility: controllers handle HTTP, services contain business logic, repositories encapsulate DB access
  • No HTTP handling in services, no business logic in controllers
  • Easy to test components in isolation

Quick Start

# Initialize project
bun init

# Install dependencies
bun add hono @hono/node-server zod @prisma/client bcrypt jsonwebtoken pino
bun add -d @types/node @types/jsonwebtoken @types/bcrypt typescript prisma @biomejs/biome @types/bun

# Initialize tools
bunx tsc --init
bunx prisma init
bunx @biomejs/biome init

package.json scripts:

{
  "scripts": {
    "dev": "bun --hot src/server.ts",
    "start": "NODE_ENV=production bun src/server.ts",
    "build": "bun build src/server.ts --target bun --outdir dist",
    "test": "bun test",
    "test:watch": "bun test --watch",
    "lint": "biome lint --write",
    "format": "biome format --write",
    "check": "biome check --write",
    "typecheck": "tsc --noEmit",
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "db:studio": "prisma studio"
  }
}

TypeScript Configuration

tsconfig.json (key settings):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "lib": ["ES2022"],
    "moduleResolution": "bundler",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "types": ["bun-types"],
    "baseUrl": ".",
    "paths": {
      "@core/*": ["src/core/*"],
      "@database/*": ["src/database/*"],
      "@services/*": ["src/services/*"],
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}

Critical settings:

  • "strict": true - Enable all strict checks
  • "moduleResolution": "bundler" - Aligns with Bun's resolver
  • Use paths for clean imports (@core/*, @services/*)
  • "types": ["bun-types"] - Bun type definitions

Code Quality with Biome

Biome replaces ESLint + Prettier with a single, fast tool.

biome.json:

{
  "$schema": "https://raw.githubusercontent.com/biomejs/biome/main/configuration_schema.json",
  "files": { "ignore": ["node_modules", "dist"] },
  "formatter": {
    "indentStyle": "space",
    "indentSize": 2,
    "lineWidth": 100,
    "quoteStyle": "single",
    "semicolons": "always"
  },
  "organizeImports": true,
  "javascript": { "formatter": { "trailingComma": "es5" } },
  "typescript": {
    "formatter": { "trailingComma": "es5" },
    "lint": { "files": { "ignore": ["src/**/__generated__/*"] } }
  }
}

Commands:

bun run check        # format + lint with autofix
bun run lint         # lint only
bun run format       # format only
biome check --changed --write  # only changed files

Error Handling Architecture

Custom error classes (src/core/errors.ts):

export enum ErrorType {
  BAD_REQUEST = 'BadRequestError',
  UNAUTHORIZED = 'UnauthorizedError',
  FORBIDDEN = 'ForbiddenError',
  NOT_FOUND = 'NotFoundError',
  CONFLICT = 'ConflictError',
  VALIDATION = 'ValidationError',
  INTERNAL = 'InternalError',
  RATE_LIMIT = 'RateLimitError'
}

export abstract class ApiError extends Error {
  constructor(
    public type: ErrorType,
    message: string,
    public statusCode: number,
    public details?: any
  ) {
    super(message);
    Object.setPrototypeOf(this, new.target.prototype);
    Error.captureStackTrace(this, this.constructor);
  }
  toJSON() {
    return {
      statusCode: this.statusCode,
      type: this.type,
      message: this.message,
      ...(process.env.NODE_ENV === 'development' && this.details ? { details: this.details } : {})
    };
  }
}

export class BadRequestError extends ApiError {
  constructor(message = 'Bad Request', details?: any) {
    super(ErrorType.BAD_REQUEST, message, 400, details);
  }
}

export class UnauthorizedError extends ApiError {
  constructor(message = 'Unauthorized', details?: any) {
    super(ErrorType.UNAUTHORIZED, message, 401, details);
  }
}

export class NotFoundError extends ApiError {
  constructor(resource: string, details?: any) {
    super(ErrorType.NOT_FOUND, `${resource} not found`, 404, details);
  }
}

export class ConflictError extends ApiError {
  constructor(message = 'Resource conflict', details?: any) {
    super(ErrorType.CONFLICT, message, 409, details);
  }
}

export class ValidationError extends ApiError {
  constructor(message = 'Validation failed', details?: any) {
    super(ErrorType.VALIDATION, message, 422, details);
  }
}

Global error handler (src/middleware/errorHandler.ts):

import type { Context } from 'hono';
import { ApiError } from '@core/errors';
import { logger } from '@core/logger';

export function errorHandler(err: Error, c: Context) {
  if (err instanceof ApiError) {
    logger.warn({ type: err.type, path: c.req.path, method: c.req.method, status: err.statusCode });
    return c.json(err.toJSON(), err.statusCode);
  }
  logger.error({ error: err.message, stack: err.stack, path: c.req.path, method: c.req.method });
  return c.json({ statusCode: 500, type: 'InternalError', message: 'Internal server error' }, 500);
}

// In app.ts
import { Hono } from 'hono';
import { errorHandler } from '@middleware/errorHandler';
export const app = new Hono();
app.onError(errorHandler);

Request Validation with Zod

Validation middleware (src/middleware/validator.ts):

import { z, ZodSchema } from 'zod';
import type { Context, Next } from 'hono';
import { ValidationError } from '@core/errors';

export const validate = (schema: ZodSchema) => async (c: Context, next: Next) => {
  try {
    const body = await c.req.json();
    c.set('validatedData', schema.parse(body));
    await next();
  } catch (e) {
    if (e instanceof z.ZodError) throw new ValidationError('Invalid request data', e.issues);
    throw e;
  }
};

export const validateQuery = (schema: ZodSchema) => async (c: Context, next: Next) => {
  try {
    c.set('validatedQuery', schema.parse(c.req.query()));
    await next();
  } catch (e) {
    if (e instanceof z.ZodError) throw new ValidationError('Invalid query parameters', e.issues);
    throw e;
  }
};

Example schemas (src/schemas/user.schema.ts):

import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string()
    .min(8)
    .regex(/[A-Z]/).regex(/[a-z]/).regex(/[0-9]/).regex(/[^A-Za-z0-9]/),
  name: z.string().min(2).max(100),
  role: z.enum(['user', 'admin', 'moderator']).default('user')
});

export const updateUserSchema = createUserSchema.partial();

export const getUsersQuerySchema = z.object({
  page: z.coerce.number().positive().default(1),
  limit: z.coerce.number().positive().max(100).default(20),
  sortBy: z.enum(['createdAt', 'name', 'email']).optional(),
  order: z.enum(['asc', 'desc']).default('desc'),
  role: z.enum(['user', 'admin', 'moderator']).optional()
});

export type CreateUserDto = z.infer<typeof createUserSchema>;
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
export type GetUsersQuery = z.infer<typeof getUsersQuerySchema>;

Routes (src/routes/user.routes.ts):

import { Hono } from 'hono';
import * as userController from '@/controllers/user.controller';
import { validate, validateQuery } from '@middleware/validator';
import { createUserSchema, updateUserSchema, getUsersQuerySchema } from '@/schemas/user.schema';

const userRouter = new Hono();
userRouter.get('/', validateQuery(getUsersQuerySchema), userController.getUsers);
userRouter.get('/:id', userController.getUserById);
userRouter.post('/', validate(createUserSchema), userController.createUser);
userRouter.patch('/:id', validate(updateUserSchema), userController.updateUser);
userRouter.delete('/:id', userController.deleteUser);
export default userRouter;

API Field Naming Convention: camelCase

CRITICAL: Always use camelCase for JSON REST API field names.

Why camelCase:

  • Native to JavaScript/JSON - No transformation needed in frontend code
  • Industry standard - Google, Microsoft, Facebook, AWS all use camelCase
  • TypeScript friendly - Direct mapping to TypeScript interfaces
  • OpenAPI/Swagger convention - Most OpenAPI examples use camelCase
  • Auto-generated clients - API client generators expect camelCase by default

Examples:

// ✅ CORRECT: camelCase
{
  "userId": "123",
  "firstName": "John",
  "lastName": "Doe",
  "emailAddress": "john@example.com",
  "createdAt": "2025-01-06T12:00:00Z",
  "isActive": true,
  "phoneNumber": "+1234567890",
  "dateOfBirth": "1990-01-01"
}

// ❌ WRONG: snake_case (Python/Ruby convention)
{
  "user_id": "123",
  "first_name": "John",
  "last_name": "Doe",
  "email_address": "john@example.com",
  "created_at": "2025-01-06T12:00:00Z",
  "is_active": true
}

// ❌ WRONG: PascalCase (C# convention)
{
  "UserId": "123",
  "FirstName": "John",
  "LastName": "Doe",
  "EmailAddress": "john@example.com"
}

// ❌ WRONG: kebab-case (not valid in JavaScript)
{
  "user-id": "123",
  "first-name": "John",
  "last-name": "Doe"
}

Consistent Application:

  1. Request Bodies: All fields in camelCase

    POST /api/users
    {
      "email": "user@example.com",
      "firstName": "Jane",
      "lastName": "Smith",
      "dateOfBirth": "1995-05-15"
    }
    
  2. Response Bodies: All fields in camelCase

    GET /api/users/123
    {
      "id": "123",
      "email": "user@example.com",
      "firstName": "Jane",
      "lastName": "Smith",
      "createdAt": "2025-01-06T12:00:00Z",
      "updatedAt": "2025-01-06T12:00:00Z"
    }
    
  3. Query Parameters: Use camelCase

    GET /api/users?pageSize=20&sortBy=createdAt&orderBy=desc
    
  4. Zod Schemas: Define fields in camelCase

    export const userSchema = z.object({
      firstName: z.string(),
      lastName: z.string(),
      emailAddress: z.string().email(),
      phoneNumber: z.string().optional(),
      dateOfBirth: z.string().datetime()
    });
    
  5. TypeScript Interfaces: Match API camelCase

    interface User {
      id: string;
      firstName: string;
      lastName: string;
      emailAddress: string;
      createdAt: Date;
      updatedAt: Date;
    }
    

Database vs API Naming:

While Prisma schema uses camelCase by default (matching JavaScript conventions), if you inherit a database with snake_case columns, use Prisma's @map() to transform:

model User {
  id        String   @id @default(cuid())
  firstName String   @map("first_name")  // DB: first_name → API: firstName
  lastName  String   @map("last_name")   // DB: last_name → API: lastName
  createdAt DateTime @default(now()) @map("created_at")

  @@map("users")
}

Key Takeaway: camelCase is the JavaScript/TypeScript/JSON ecosystem standard. Using it ensures seamless integration with frontend code, API tools, and the broader JavaScript ecosystem.

Database Naming Conventions: camelCase

CRITICAL: All database identifiers (tables, columns, indexes, constraints) use camelCase.

Why camelCase in Databases:

  • Stack consistency - TypeScript is our primary language across backend and frontend
  • Zero translation layer - Database names map 1:1 with TypeScript types
  • Reduced complexity - No snake_case ↔ camelCase conversion needed
  • Modern ORM compatibility - Prisma, Drizzle, TypeORM work seamlessly with camelCase
  • Team productivity - Full-stack TypeScript developers think in camelCase

Yes, we know: PostgreSQL traditionally uses snake_case. We're deliberately deviating because our technology stack is TypeScript-first, and the benefits of consistency across all layers outweigh adherence to legacy SQL conventions.

Naming Rules

1. Tables: Use singular, camelCase

-- ✅ Good
users
orderItems
userPreferences

-- ❌ Bad
user_profiles
OrderItems

2. Columns: Use camelCase

-- ✅ Good
userId, firstName, emailAddress, createdAt, isActive

-- ❌ Bad
user_id, first_name, email_address, created_at, is_active

3. Primary Keys: {tableName}Id

userId    -- in users table
orderId   -- in orders table
productId -- in products table

4. Foreign Keys: Same as the referenced primary key

-- orders table references users table
userId  -- references users.userId

5. Boolean Fields: Prefix with is/has/can

isActive, isDeleted, isPublic
hasPermission, hasAccess
canEdit, canDelete

6. Timestamps: Consistent suffixes

createdAt      -- creation time
updatedAt      -- last modification
deletedAt      -- soft delete time
lastLoginAt    -- specific event times
publishedAt
verifiedAt

7. Indexes: idx{TableName}{ColumnName}

idxUsersEmailAddress
idxOrdersUserIdCreatedAt
idxProductsCreatedAt

8. Constraints:

-- Foreign keys: fk{TableName}{ColumnName}
fkOrdersUserId

-- Unique constraints: unq{TableName}{ColumnName}
unqUsersEmailAddress

-- Check constraints: chk{TableName}{Description}
chkUsersAgePositive

PostgreSQL Schema Example

CREATE TABLE users (
  userId UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  emailAddress VARCHAR(255) NOT NULL,
  firstName VARCHAR(100),
  lastName VARCHAR(100),
  phoneNumber VARCHAR(20),
  isActive BOOLEAN DEFAULT true,
  createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  lastLoginAt TIMESTAMP
);

CREATE INDEX idxUsersEmailAddress ON users(emailAddress);
CREATE INDEX idxUsersCreatedAt ON users(createdAt);

CREATE TABLE orders (
  orderId UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  userId UUID NOT NULL,
  totalAmount DECIMAL(10,2),
  orderStatus VARCHAR(50),
  createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fkOrdersUserId FOREIGN KEY (userId) REFERENCES users(userId)
);

ALTER TABLE users ADD CONSTRAINT unqUsersEmailAddress UNIQUE (emailAddress);

Prisma Schema Example

model User {
  userId       String   @id @default(cuid())
  emailAddress String   @unique
  firstName    String?
  lastName     String?
  phoneNumber  String?
  isActive     Boolean  @default(true)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
  lastLoginAt  DateTime?

  orders       Order[]

  @@index([emailAddress])
  @@index([createdAt])
  @@map("users")
}

model Order {
  orderId      String   @id @default(cuid())
  userId       String
  totalAmount  Decimal  @db.Decimal(10, 2)
  orderStatus  String
  createdAt    DateTime @default(now())

  user         User     @relation(fields: [userId], references: [userId])

  @@index([userId])
  @@index([createdAt])
  @@map("orders")
}

TypeScript Types (Perfect Match)

// Exact 1:1 mapping with database
interface User {
  userId: string;
  emailAddress: string;
  firstName: string | null;
  lastName: string | null;
  phoneNumber: string | null;
  isActive: boolean;
  createdAt: Date;
  updatedAt: Date;
  lastLoginAt: Date | null;
}

interface Order {
  orderId: string;
  userId: string;
  totalAmount: number;
  orderStatus: string;
  createdAt: Date;
}

MongoDB Collection Example

// users collection
{
  userId: "550e8400-e29b-41d4-a716-446655440000",
  emailAddress: "user@example.com",
  firstName: "John",
  lastName: "Doe",
  phoneNumber: "+1234567890",
  isActive: true,
  createdAt: ISODate("2025-11-06T10:00:00Z"),
  updatedAt: ISODate("2025-11-06T10:00:00Z"),
  lastLoginAt: ISODate("2025-11-06T12:30:00Z")
}

Special Cases

Acronyms: Keep as proper words

userId      // not userID
apiKey      // not APIKey
htmlContent // not HTMLContent
urlPath     // not URLPath

// Exception: When acronym is the entire word
id, api     // acceptable

Reserved Keywords: Avoid them, but if unavoidable:

-- Quote in PostgreSQL if needed
CREATE TABLE orders (
  "order" UUID,
  "group" VARCHAR(50)
);

-- Better: Use more descriptive names
orderType, orderGroup

JSONB Columns: Column and content both use camelCase

CREATE TABLE users (
  userId UUID PRIMARY KEY,
  metadata JSONB  -- column name: camelCase
);

-- JSONB content also camelCase
{
  "preferredLanguage": "en",
  "notificationSettings": {
    "emailEnabled": true,
    "pushEnabled": false
  }
}

Migration from snake_case

If migrating from snake_case databases, use Prisma's @map():

model User {
  userId    String @id @map("user_id")       // DB: user_id → App: userId
  firstName String @map("first_name")        // DB: first_name → App: firstName
  createdAt DateTime @default(now()) @map("created_at")

  @@map("users")
}

Eventually migrate the actual database columns to camelCase and remove @map().

Benefits of This Approach

Single Convention Everywhere:

// Database column
userId

// Prisma model
userId

// TypeScript type
userId

// API response
userId

// Frontend state
userId

// No translation needed anywhere ✓

Eliminates Entire Class of Bugs:

  • No mapping errors between layers
  • No "which convention am I using now?" confusion
  • Autocomplete works perfectly everywhere
  • Type safety maintained end-to-end

Key Takeaway: While this deviates from traditional PostgreSQL conventions, it's optimal for TypeScript-first full-stack development. Consistency and productivity gains far outweigh adherence to legacy SQL naming conventions.

Database with Prisma

Prisma client setup (src/database/client.ts):

import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
  globalForPrisma.prisma ?? new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
  });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
process.on('beforeExit', async () => prisma.$disconnect());

Repository pattern (src/database/repositories/user.repository.ts):

import { prisma } from '@/database/client';
import type { Prisma, User } from '@prisma/client';

export class UserRepository {
  findById(id: string): Promise<User | null> {
    return prisma.user.findUnique({ where: { id } });
  }
  findByEmail(email: string): Promise<User | null> {
    return prisma.user.findUnique({ where: { email } });
  }
  create(data: Prisma.UserCreateInput) {
    return prisma.user.create({ data });
  }
  update(id: string, data: Prisma.UserUpdateInput) {
    return prisma.user.update({ where: { id }, data });
  }
  async delete(id: string) {
    await prisma.user.delete({ where: { id } });
  }
  async exists(email: string) {
    return (await prisma.user.count({ where: { email } })) > 0;
  }
  async findMany(options: {
    skip?: number; take?: number;
    where?: Prisma.UserWhereInput;
    orderBy?: Prisma.UserOrderByWithRelationInput;
  }) {
    const [users, total] = await prisma.$transaction([
      prisma.user.findMany(options),
      prisma.user.count({ where: options.where })
    ]);
    return { users, total };
  }
}
export const userRepository = new UserRepository();

Service layer (src/services/user.service.ts):

import { userRepository } from '@/database/repositories/user.repository';
import { NotFoundError, ConflictError } from '@core/errors';
import type { CreateUserDto, GetUsersQuery } from '@/schemas/user.schema';
import bcrypt from 'bcrypt';

export const createUser = async (data: CreateUserDto) => {
  if (await userRepository.exists(data.email)) throw new ConflictError('Email already exists');
  const hashedPassword = await bcrypt.hash(data.password, 10);
  const user = await userRepository.create({ ...data, password: hashedPassword });
  const { password, ...withoutPassword } = user;
  return withoutPassword;
};

export const getUserById = async (id: string) => {
  const user = await userRepository.findById(id);
  if (!user) throw new NotFoundError('User');
  const { password, ...withoutPassword } = user;
  return withoutPassword;
};

export const getUsers = async (query: GetUsersQuery) => {
  const { page, limit, sortBy, order, role } = query;
  const { users, total } = await userRepository.findMany({
    skip: (page - 1) * limit,
    take: limit,
    where: role ? { role } : undefined,
    orderBy: sortBy ? { [sortBy]: order } : { createdAt: order }
  });
  return {
    data: users.map(({ password, ...u }) => u),
    pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
  };
};

Prisma commands:

bunx prisma generate         # Generate client
bunx prisma migrate dev      # Create migration
bunx prisma migrate deploy   # Apply migrations (prod)
bunx prisma studio          # GUI for DB
bunx prisma db seed         # Seed database
bunx prisma format          # Format schema

Authentication & Security

JWT Authentication (src/services/auth.service.ts):

import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { userRepository } from '@/database/repositories/user.repository';
import { prisma } from '@/database/client';
import { UnauthorizedError } from '@core/errors';

const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret';
const ACCESS_EXPIRES = '15m';
const REFRESH_EXPIRES = '7d';

interface TokenPayload { userId: string; email: string; role: string; }

export const generateAccessToken = (p: TokenPayload) =>
  jwt.sign(p, JWT_SECRET, { expiresIn: ACCESS_EXPIRES });

export const generateRefreshToken = (p: TokenPayload) =>
  jwt.sign(p, JWT_SECRET, { expiresIn: REFRESH_EXPIRES });

export const verifyToken = (t: string) => jwt.verify(t, JWT_SECRET) as TokenPayload;

export const login = async (email: string, password: string) => {
  const user = await userRepository.findByEmail(email);
  if (!user) throw new UnauthorizedError('Invalid credentials');
  const ok = await bcrypt.compare(password, user.password);
  if (!ok) throw new UnauthorizedError('Invalid credentials');
  const payload = { userId: user.id, email: user.email, role: user.role };
  const accessToken = generateAccessToken(payload);
  const refreshToken = generateRefreshToken(payload);
  await prisma.session.create({
    data: { userId: user.id, token: refreshToken,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }
  });
  return {
    accessToken,
    refreshToken,
    user: { id: user.id, email: user.email, name: user.name, role: user.role }
  };
};

Auth middleware (src/middleware/auth.ts):

import type { Context, Next } from 'hono';
import { verifyToken } from '@/services/auth.service';
import { UnauthorizedError, ForbiddenError } from '@core/errors';

export const authenticate = async (c: Context, next: Next) => {
  const header = c.req.header('Authorization');
  if (!header?.startsWith('Bearer ')) throw new UnauthorizedError('Missing or invalid token');
  try { c.set('user', verifyToken(header.slice(7))); await next(); }
  catch { throw new UnauthorizedError('Invalid or expired token'); }
};

export const authorize = (...roles: string[]) => async (c: Context, next: Next) => {
  const user = c.get('user') as { role: string } | undefined;
  if (!user) throw new UnauthorizedError('Authentication required');
  if (!roles.includes(user.role)) throw new ForbiddenError('Insufficient permissions');
  await next();
};

Security headers (src/middleware/security.ts):

import type { Context, Next } from 'hono';

export const securityHeaders = async (c: Context, next: Next) => {
  await next();
  c.header('X-Content-Type-Options', 'nosniff');
  c.header('X-Frame-Options', 'DENY');
  c.header('X-XSS-Protection', '1; mode=block');
  c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
};

CORS (in app.ts):

import { cors } from 'hono/cors';
app.use('*', cors({
  origin: ['http://localhost:3000', 'https://yourapp.com'],
  allowHeaders: ['Authorization', 'Content-Type'],
  allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  credentials: true,
  maxAge: 86400
}));

Structured Logging with Pino

Logger setup (src/core/logger.ts):

import pino from 'pino';
const isDev = process.env.NODE_ENV === 'development';
export const logger = pino({
  level: process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'),
  transport: isDev ? {
    target: 'pino-pretty',
    options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' }
  } : undefined,
  base: undefined,
  formatters: { level: (label) => ({ level: label }) }
});

Request logging (src/middleware/requestLogger.ts):

import type { Context, Next } from 'hono';
import { logger } from '@core/logger';

export const requestLogger = async (c: Context, next: Next) => {
  const start = Date.now();
  const reqId = crypto.randomUUID();
  c.set('requestId', reqId);
  logger.info({ type: 'request', reqId, method: c.req.method, path: c.req.path, query: c.req.query() });
  await next();
  logger.info({ type: 'response', reqId, status: c.res.status, duration: `${Date.now() - start}ms` });
};

Testing with Bun

Bun includes a fast, built-in test runner with Jest-like APIs.

Unit test example:

// tests/unit/services/user.service.test.ts
import { describe, it, expect } from 'bun:test';
import { createUser } from '@/services/user.service';

describe('UserService', () => {
  it('creates a user and strips password', async () => {
    const user = await createUser({ email: 'a@b.com', password: 'Abcdef1!', name: 'A', role: 'user' });
    expect(user).toHaveProperty('email', 'a@b.com');
    expect(user).not.toHaveProperty('password');
  });
});

Integration test example:

// tests/integration/api/user.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { app } from '@/app';
import { prisma } from '@/database/client';

describe('User API', () => {
  beforeAll(async () => { await prisma.$connect(); await prisma.user.deleteMany(); });
  afterAll(async () => { await prisma.user.deleteMany(); await prisma.$disconnect(); });

  it('POST /api/users creates a user', async () => {
    const res = await app.request('http://localhost/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: 'new@example.com', password: 'Abcdef1!', name: 'New User' })
    });
    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.email).toBe('new@example.com');
  });
});

Commands:

bun test              # Run all tests
bun test --watch      # Watch mode
bun test --coverage   # With coverage

Performance: Caching with Redis

Cache utilities (src/utils/cache.ts):

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');

export async function cacheGet<T>(key: string): Promise<T | null> {
  const v = await redis.get(key);
  return v ? JSON.parse(v) : null;
}

export async function cacheSet(key: string, value: any, ttlSeconds: number) {
  await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
}

export async function cached<T>(key: string, ttl: number, fn: () => Promise<T>): Promise<T> {
  const hit = await cacheGet<T>(key);
  if (hit) return hit;
  const val = await fn();
  await cacheSet(key, val, ttl);
  return val;
}

Usage:

export const getUserProfile = (userId: string) =>
  cached(`userProfile:${userId}`, 300, async () => {
    const u = await userRepository.findById(userId);
    if (!u) throw new NotFoundError('User');
    const { password, ...profile } = u;
    return profile;
  });

Docker & Production

Production Dockerfile (multi-stage):

# Stage 1: Base
FROM oven/bun:1-alpine AS base
WORKDIR /app

# Stage 2: Deps
FROM base AS deps
COPY package.json bun.lockb ./
COPY prisma ./prisma/
RUN bun install --frozen-lockfile --production

# Stage 3: Build
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bunx prisma generate

# Stage 4: Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 bungroup && adduser -D -u 1001 -G bungroup bunuser
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/src ./src
COPY --from=build /app/prisma ./prisma
COPY package.json bun.lockb ./
USER bunuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1
CMD ["bun", "src/server.ts"]

Server with graceful shutdown (src/server.ts):

import { serve } from '@hono/node-server';
import { app } from './app';
import { prisma } from '@/database/client';
import { logger } from '@core/logger';

const PORT = Number(process.env.PORT) || 3000;
const server = serve({ fetch: app.fetch, port: PORT });
logger.info(`🚀 Server running on port ${PORT}`);

async function shutdown(signal: string) {
  logger.info(`Received ${signal}, shutting down...`);
  try { await prisma.$disconnect(); } catch {}
  process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Production Readiness Checklist

Security

  • No secrets in code (use AWS Secrets Manager/SSM)
  • Password hashing with bcrypt
  • JWT with reasonable expiries (15m access, 7d refresh)
  • CORS restricted to known origins
  • Rate limiting enabled
  • Security headers (CSP, X-Frame-Options, etc.)
  • Least-privilege DB user

Performance

  • DB indexes on frequently queried fields
  • Query optimization (select only needed fields)
  • Redis caching for expensive operations
  • Compression enabled (gzip/brotli)
  • Connection pooling configured

Reliability

  • Health checks (/health endpoint)
  • Graceful shutdown handling
  • Structured logging (Pino)
  • Monitoring & alerts (CloudWatch)
  • Database backups & DR plan

Quality

  • Tests passing with good coverage
  • Biome checks passing (format + lint)
  • TypeScript strict mode enabled
  • No console.log in production code
  • Error handling comprehensive

Deployment

  • CI/CD pipeline working
  • Migrations tested and reversible
  • Rollback strategy defined
  • Zero-downtime deployments
  • Staging environment with prod parity

Environment Variables

.env files:

# Development
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="dev-secret"
NODE_ENV="development"
LOG_LEVEL="debug"

# Test
DATABASE_URL="postgresql://user:password@localhost:5432/mydb_test?schema=public"
NODE_ENV="test"

# Production (use Secrets Manager)
DATABASE_URL="postgresql://user:password@prod-host:5432/mydb_prod?schema=public&connection_limit=10&pool_timeout=60"
REDIS_URL="redis://prod-elasticache:6379"
JWT_SECRET="<strong-random-secret>"
NODE_ENV="production"
LOG_LEVEL="info"

CI/CD with GitHub Actions

Key principles:

  • Run on every push and PR
  • Test with real services (PostgreSQL, Redis)
  • Cache dependencies for speed
  • Fail fast on errors

.github/workflows/ci.yml should include:

  • Bun setup
  • Biome check
  • TypeScript type check
  • Prisma generate + migrate
  • Run tests with coverage
  • Build Docker image (on main)

AWS ECS Deployment

Best practices:

  • Run behind Application Load Balancer
  • Use health checks on /health endpoint
  • Store secrets in AWS Secrets Manager
  • Use private subnets + security groups
  • Enable CloudWatch logs & Container Insights
  • Configure Auto Scaling (CPU/Memory/Requests)
  • Use Fargate for serverless containers

Bun-Specific Tips

  • Use bun --hot for dev (preserves state)
  • bun build for production bundles (optional)
  • Run .ts files directly (no build step needed)
  • Check compatibility tracker for new packages
  • Leverage Workers for CPU-heavy tasks
  • WebSockets supported natively

Key Takeaways

  1. Architecture: Clean separation of concerns (routes → controllers → services → repositories)
  2. Type Safety: TypeScript strict + Zod + Prisma = end-to-end type safety
  3. Error Handling: Custom error classes + global error handler + structured logging
  4. Validation: Zod schemas for all inputs (body, query, params)
  5. Security: Auth middleware, CORS, rate limiting, security headers, password hashing
  6. Testing: Bun's native test runner for unit + integration tests
  7. Performance: Redis caching, DB indexes, query optimization
  8. Production: Docker multi-stage builds, health checks, graceful shutdown, secrets management
  9. Developer Experience: Bun's speed + hot reload + unified tooling = fast iteration

Last Updated: November 2025 Tool Versions: Bun 1.x, TypeScript 5.7, Prisma 6.2, Hono 4.6, Biome 2.3, PostgreSQL 17 Target Environment: AWS ECS Fargate, GitHub Actions CI, Docker, Redis (ElastiCache), RDS PostgreSQL