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

1224 lines
36 KiB
Markdown

# 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
```bash
# 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:**
```json
{
"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):**
```json
{
"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:**
```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:**
```bash
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):**
```typescript
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):**
```typescript
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):**
```typescript
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):**
```typescript
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):**
```typescript
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:**
```typescript
// ✅ 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
```typescript
POST /api/users
{
"email": "user@example.com",
"firstName": "Jane",
"lastName": "Smith",
"dateOfBirth": "1995-05-15"
}
```
2. **Response Bodies**: All fields in camelCase
```typescript
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
```typescript
GET /api/users?pageSize=20&sortBy=createdAt&orderBy=desc
```
4. **Zod Schemas**: Define fields in camelCase
```typescript
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
```typescript
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:
```prisma
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
```sql
-- ✅ Good
users
orderItems
userPreferences
-- ❌ Bad
user_profiles
OrderItems
```
**2. Columns:** Use camelCase
```sql
-- ✅ Good
userId, firstName, emailAddress, createdAt, isActive
-- ❌ Bad
user_id, first_name, email_address, created_at, is_active
```
**3. Primary Keys:** `{tableName}Id`
```sql
userId -- in users table
orderId -- in orders table
productId -- in products table
```
**4. Foreign Keys:** Same as the referenced primary key
```sql
-- orders table references users table
userId -- references users.userId
```
**5. Boolean Fields:** Prefix with is/has/can
```sql
isActive, isDeleted, isPublic
hasPermission, hasAccess
canEdit, canDelete
```
**6. Timestamps:** Consistent suffixes
```sql
createdAt -- creation time
updatedAt -- last modification
deletedAt -- soft delete time
lastLoginAt -- specific event times
publishedAt
verifiedAt
```
**7. Indexes:** `idx{TableName}{ColumnName}`
```sql
idxUsersEmailAddress
idxOrdersUserIdCreatedAt
idxProductsCreatedAt
```
**8. Constraints:**
```sql
-- Foreign keys: fk{TableName}{ColumnName}
fkOrdersUserId
-- Unique constraints: unq{TableName}{ColumnName}
unqUsersEmailAddress
-- Check constraints: chk{TableName}{Description}
chkUsersAgePositive
```
### PostgreSQL Schema Example
```sql
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
```prisma
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)
```typescript
// 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
```javascript
// 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
```typescript
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:
```sql
-- 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
```sql
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()`:
```prisma
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:**
```typescript
// 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):**
```typescript
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):**
```typescript
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):**
```typescript
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:**
```bash
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):**
```typescript
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):**
```typescript
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):**
```typescript
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):**
```typescript
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):**
```typescript
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):**
```typescript
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:**
```typescript
// 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:**
```typescript
// 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:**
```bash
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):**
```typescript
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:**
```typescript
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):**
```dockerfile
# 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):**
```typescript
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:**
```bash
# 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