01 — Architecture
THE LAYER CONTRACT
Five clean layers. Each has a single responsibility. Elysia's plugin system enforces boundaries at the framework level — plugins register routes, not business logic.
02 — Project Structure
FEATURE-FIRST LAYOUT
Organised by feature, not by technical role. Each feature is a plugin that owns its routes, service, and repository. Scales to monorepo cleanly.
03 — Code Patterns
TYPESCRIPT BLUEPRINTS
Production-ready TypeScript. Fully typed end-to-end — from Drizzle schema to Elysia response to the Eden Treaty client.
import { Elysia } from 'elysia' import { swagger } from '@elysiajs/swagger' import { cors } from '@elysiajs/cors' import { authPlugin } from './features/auth/auth.plugin' import { productsPlugin } from './features/products/products.plugin' import { jwtPlugin } from './plugins/jwt.plugin' import { rateLimitPlugin } from './plugins/rateLimit.plugin' import { errorHandler } from './plugins/errorHandler.plugin' import { config } from './infrastructure/config' export const app = new Elysia() .use(swagger({ path: '/docs' })) .use(cors()) .use(rateLimitPlugin) .use(errorHandler) .use(jwtPlugin) // decorates .jwt, .guard .use(authPlugin) // /auth/login, /auth/register .use(productsPlugin) // /products CRUD .listen(config.PORT) // Export type for Eden Treaty client inference export type App = typeof app
import { Elysia, t } from 'elysia' import { AuthService } from './auth.service' import { loginSchema, registerSchema } from './auth.schema' export const authPlugin = new Elysia({ prefix: '/auth' }) .decorate('authService', new AuthService()) .post('/login', async ({ body, authService, jwt, cookie }) => { const { user, tokens } = await authService.login(body) cookie.refresh.set({ value: tokens.refresh, httpOnly: true }) return { accessToken: tokens.access, user } }, { body: loginSchema, detail: { summary: 'User login', tags: ['Auth'] }, }) .post('/register', async ({ body, authService }) => authService.register(body) , { body: registerSchema }) .post('/refresh', async ({ cookie, authService, jwt }) => { const payload = await jwt.verify(cookie.refresh.value) if (!payload) throw new UnauthorizedError() return authService.refreshTokens(payload.sub as string) })
import { Elysia, t } from 'elysia' import { jwtPlugin } from '../../plugins/jwt.plugin' import { ProductService} from './products.service' export const productsPlugin = new Elysia({ prefix: '/products' }) .use(jwtPlugin) // re-use shared plugin .decorate('productService', new ProductService()) // guard: all routes below require valid JWT .guard({ beforeHandle: ['isAuthenticated'] }, (app) => app .get('/', ({ productService, query }) => productService.list(query), { query: t.Object({ page: t.Optional(t.Number()), limit: t.Optional(t.Number()), search: t.Optional(t.String()), }), }) .post('/', ({ body, productService, user }) => productService.create(body, user.id), { body: t.Object({ name: t.String({ minLength: 1 }), price: t.Number({ minimum: 0 }), sku: t.String(), }), }) .delete('/:id', ({ params, productService, user }) => productService.delete(params.id, user.id)) )
import { Elysia } from 'elysia' import { AppError } from '../domain/errors/AppError' import { logger } from '../infrastructure/logger' export const errorHandler = new Elysia() .error({ AppError }) .onError(({ code, error, set }) => { if (error instanceof AppError) { set.status = error.statusCode return { success: false, code: error.code, message: error.message, } } if (code === 'VALIDATION') { set.status = 422 return { success: false, code: 'VALIDATION_ERROR', message: error.message, } } logger.error(error, 'Unhandled error') set.status = 500 return { success: false, code: 'INTERNAL', message: 'Internal server error' } })
// Pure TypeScript — zero DB/framework imports export interface User { id: string email: string name: string role: UserRole passwordHash: string createdAt: Date } export type UserRole = 'admin' | 'user' | 'moderator' export type CreateUserInput = Pick<User, 'email' | 'name'> & { password: string } export type PublicUser = Omit<User, 'passwordHash'>
import type { User, CreateUserInput } from '../user.entity' export interface IUserRepository { findById(id: string): Promise<User | null> findByEmail(email: string): Promise<User | null> create(data: CreateUserInput): Promise<User> update(id: string, data: Partial<User>): Promise<User> delete(id: string): Promise<void> list(opts: ListOptions): Promise<Paginated<User>> } export interface ListOptions { page?: number limit?: number search?: string } export interface Paginated<T> { data: T[] total: number page: number pages: number }
import { UserRepository } from './auth.repository' import { CryptoService } from '../../infrastructure/crypto/CryptoService' import { TokenService } from '../../infrastructure/token/TokenService' import { UnauthorizedError, ConflictError } from '../../domain/errors/AppError' export class AuthService { constructor( private userRepo = new UserRepository(), private crypto = new CryptoService(), private tokenSvc = new TokenService(), ) {} async login({ email, password }: LoginInput) { const user = await this.userRepo.findByEmail(email) if (!user) throw new UnauthorizedError('Invalid credentials') const valid = await Bun.password.verify(password, user.passwordHash) if (!valid) throw new UnauthorizedError('Invalid credentials') const tokens = await this.tokenSvc.generatePair(user.id, user.role) return { user: toPublicUser(user), tokens } } async register(input: CreateUserInput) { const exists = await this.userRepo.findByEmail(input.email) if (exists) throw new ConflictError('Email already registered') // Bun.password uses Argon2id by default const passwordHash = await Bun.password.hash(input.password) return this.userRepo.create({ ...input, passwordHash }) } }
export class AppError extends Error { constructor( public readonly message: string, public readonly statusCode: number, public readonly code: string, ) { super(message) } } export class NotFoundError extends AppError { constructor(resource = 'Resource') { super(`${resource} not found`, 404, 'NOT_FOUND') } } export class UnauthorizedError extends AppError { constructor(msg = 'Unauthorized') { super(msg, 401, 'UNAUTHORIZED') } } export class ForbiddenError extends AppError { constructor(msg = 'Forbidden') { super(msg, 403, 'FORBIDDEN') } } export class ConflictError extends AppError { constructor(msg: string) { super(msg, 409, 'CONFLICT') } }
import { sqliteTable, text, real, integer } from 'drizzle-orm/sqlite-core' import { sql } from 'drizzle-orm' export const users = sqliteTable('users', { id: text('id').primaryKey().$defaultFn( () => crypto.randomUUID()), email: text('email').notNull().unique(), name: text('name').notNull(), passwordHash: text('password_hash').notNull(), role: text('role', { enum: ['admin', 'user', 'moderator'] }).notNull().default('user'), createdAt: integer('created_at', { mode: 'timestamp' }) .notNull().default(sql`(unixepoch())`), }) export const products = sqliteTable('products', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), name: text('name').notNull(), price: real('price').notNull(), sku: text('sku').notNull().unique(), createdBy: text('created_by').references(() => users.id), createdAt: integer('created_at', { mode: 'timestamp' }) .notNull().default(sql`(unixepoch())`), }) // Type inference — zero manual interface duplication export type UserRow = typeof users.$inferSelect export type NewUser = typeof users.$inferInsert export type ProductRow = typeof products.$inferSelect
import { drizzle } from 'drizzle-orm/bun-sqlite' import { Database } from 'bun:sqlite' import * as schema from './schema' import { config } from '../config' // Bun.sqlite is built-in — zero native deps const sqlite = new Database(config.DATABASE_URL, { create: true, readwrite: true, }) // Enable WAL mode for concurrent reads sqlite.exec('PRAGMA journal_mode=WAL;') sqlite.exec('PRAGMA foreign_keys=ON;') export const db = drizzle(sqlite, { schema, logger: true }) export type DB = typeof db // For PostgreSQL production — swap the driver: // import { drizzle } from 'drizzle-orm/postgres-js' // import postgres from 'postgres' // export const db = drizzle(postgres(config.DATABASE_URL))
import { eq, ilike, count, sql } from 'drizzle-orm' import { db } from '../../infrastructure/database/db' import { users } from '../../infrastructure/database/schema' import type { IUserRepository, ListOptions } from '../../domain/repositories/IUserRepository' import { NotFoundError } from '../../domain/errors/AppError' export class UserRepository implements IUserRepository { async findById(id: string) { return db.query.users.findFirst({ where: eq(users.id, id) }) ?? null } async findByEmail(email: string) { return db.query.users.findFirst({ where: eq(users.email, email) }) ?? null } async create(data: NewUser) { const [row] = await db.insert(users).values(data).returning() return row } async list({ page = 1, limit = 20, search }: ListOptions) { const offset = (page - 1) * limit const where = search ? ilike(users.name, `%${search}%`) : undefined const [data, [{ total }]] = await Promise.all([ db.select().from(users).where(where).limit(limit).offset(offset), db.select({ total: count() }).from(users).where(where), ]) return { data, total, page, pages: Math.ceil(total / limit) } } }
import { Redis } from 'ioredis' import { config } from './config' export const redis = new Redis(config.REDIS_URL, { maxRetriesPerRequest: 3, lazyConnect: true, }) // Generic typed cache helper export async function cached<T>( key: string, ttlSeconds: number, fetcher: () => Promise<T>, ): Promise<T> { const hit = await redis.get(key) if (hit) return JSON.parse(hit) as T const value = await fetcher() await redis.setex(key, ttlSeconds, JSON.stringify(value)) return value } // In a service: // return cached(`user:${id}`, 300, () => this.userRepo.findById(id))
import { Elysia } from 'elysia' import { jwt } from '@elysiajs/jwt' import { config } from '../infrastructure/config' import { UnauthorizedError, ForbiddenError } from '../domain/errors/AppError' import type { UserRole } from '../domain/user.entity' export const jwtPlugin = new Elysia({ name: 'jwt-plugin' }) .use(jwt({ name: 'jwt', secret: config.JWT_SECRET, exp: '15m', // short-lived access token })) // Decode + attach user to context .derive(async ({ jwt, headers, set }) => { const token = headers.authorization?.split(' ')[1] if (!token) return { user: null } const payload = await jwt.verify(token) return { user: payload || null } }) // Macro — reusable guard .macro(({ onBeforeHandle }) => ({ isAuthenticated(required: boolean = true) { if (!required) return onBeforeHandle(({ user }) => { if (!user) throw new UnauthorizedError() }) }, requireRole(...roles: UserRole[]) { onBeforeHandle(({ user }) => { if (!user) throw new UnauthorizedError() if (!roles.includes(user.role as UserRole)) throw new ForbiddenError('Insufficient permissions') }) }, }))
import { Elysia } from 'elysia' import { rateLimit } from 'elysia-rate-limit' import { redis } from '../infrastructure/redis' export const rateLimitPlugin = new Elysia() .use(rateLimit({ duration: 60_000, // 1 minute window max: 100, // 100 req/min default generator: (req) => req.headers.get('x-forwarded-for') || 'anonymous', storage: redis, skip: (req) => req.url.includes('/health'), })) // Stricter limit on auth routes .group('/auth', (app) => app .use(rateLimit({ max: 10, duration: 60_000 })) )
// Admin-only route — one line export const adminPlugin = new Elysia({ prefix: '/admin' }) .use(jwtPlugin) .get('/users', ({ userService }) => userService.listAll(), { requireRole: ['admin'], // RBAC macro }) .delete('/users/:id', ({ params, userService }) => userService.hardDelete(params.id), { requireRole: ['admin'], }) // Mixed — authenticated but any role .get('/profile', ({ user, userService }) => userService.getProfile(user.sub), { isAuthenticated: true, })
// Bun exposes the Web Crypto API natively export class CryptoService { private key: CryptoKey | null = null async init(base64Secret: string) { const raw = Buffer.from(base64Secret, 'base64') this.key = await crypto.subtle.importKey( 'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'], ) } async encrypt(plaintext: string): Promise<{ iv: string, ciphertext: string }> { const iv = crypto.getRandomValues(new Uint8Array(12)) const enc = new TextEncoder() const cipherBuf = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, this.key!, enc.encode(plaintext), ) return { iv: Buffer.from(iv).toString('base64'), ciphertext: Buffer.from(cipherBuf).toString('base64'), } } async decrypt(iv: string, ciphertext: string): Promise<string> { const plainBuf = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: Buffer.from(iv, 'base64') }, this.key!, Buffer.from(ciphertext, 'base64'), ) return new TextDecoder().decode(plainBuf) } }
import { t, type Static } from 'elysia' const ConfigSchema = t.Object({ PORT: t.String({ default: '3000' }), NODE_ENV: t.Union([ t.Literal('development'), t.Literal('production'), t.Literal('test'), ]), DATABASE_URL: t.String(), REDIS_URL: t.String(), JWT_SECRET: t.String({ minLength: 32 }), JWT_REFRESH_SECRET: t.String({ minLength: 32 }), CRYPTO_SECRET: t.String({ minLength: 32 }), CORS_ORIGINS: t.String({ default: '*' }), }) function loadConfig(): Static<typeof ConfigSchema> { const result = Value.Check(ConfigSchema, process.env) if (!result) { const errors = [...Value.Errors(ConfigSchema, process.env)] throw new Error(`Invalid config:\n${errors.map(e => e.message).join('\n')}`) } return process.env as Static<typeof ConfigSchema> } export const config = loadConfig() // throws at startup if .env invalid
import { Elysia, t } from 'elysia' import { jwtPlugin } from '../../plugins/jwt.plugin' // In-memory pub/sub — swap for Redis pub/sub in prod const rooms = new Map<string, Set<any>>() export const chatPlugin = new Elysia({ prefix: '/ws' }) .use(jwtPlugin) .ws('/chat/:room', { // Validate query token before upgrade async beforeHandle({ query, jwt }) { const payload = await jwt.verify(query.token) if (!payload) return new Response('Unauthorized', { status: 401 }) }, open(ws) { const room = ws.data.params.room if (!rooms.has(room)) rooms.set(room, new Set()) rooms.get(room)!.add(ws) ws.subscribe(room) // Bun native pub/sub }, message(ws, msg) { const room = ws.data.params.room // broadcast to all in room ws.publish(room, JSON.stringify({ type: 'message', from: ws.data.user?.sub, data: msg, at: Date.now(), })) }, close(ws) { const room = ws.data.params.room rooms.get(room)?.delete(ws) ws.unsubscribe(room) }, body: t.Object({ text: t.String() }), query: t.Object({ token: t.String() }), })
// Real-time events without WS overhead import { Elysia } from 'elysia' export const notificationsPlugin = new Elysia() .get('/notifications/stream', ({ user, set }) => { set.headers = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', } return new ReadableStream({ async start(controller) { const send = (event: string, data: unknown) => controller.enqueue( new TextEncoder().encode( `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` ) ) // Subscribe to Redis pub/sub channel per user const sub = redis.duplicate() await sub.subscribe(`user:${user.sub}:events`) sub.on('message', (_, msg) => { const { event, data } = JSON.parse(msg) send(event, data) }) }, }) }, { isAuthenticated: true })
import pino from 'pino' import { config } from './config' export const logger = pino({ level: config.NODE_ENV === 'production' ? 'info' : 'debug', transport: config.NODE_ENV !== 'production' ? { target: 'pino-pretty', options: { colorize: true } } : undefined, // Structured fields on every log base: { env: config.NODE_ENV, service: 'api', version: process.env.npm_package_version, }, // Redact sensitive fields redact: ['body.password', 'headers.authorization'], }) // Request logging Elysia plugin export const loggerPlugin = new Elysia() .onRequest(({ request }) => { logger.info({ method: request.method, url: request.url }, '→ request') }) .onAfterHandle(({ request, set }) => { logger.info({ status: set.status, url: request.url }, '← response') })
import { describe, it, expect, beforeAll } from 'bun:test' import { treaty } from '@elysiajs/eden' import { app } from '../index' // Eden Treaty gives fully typed test client const api = treaty(app) describe('Auth', () => { it('registers a new user', async () => { const { data, error } = await api.auth.register.post({ email: 'test@example.com', name: 'Test User', password: 'SecurePass123!', }) expect(error).toBeNull() expect(data?.email).toBe('test@example.com') }) it('rejects duplicate email', async () => { const { error } = await api.auth.register.post({ email: 'test@example.com', // same email name: 'Another', password: 'Pass123!', }) expect(error?.status).toBe(409) }) it('logs in and returns access token', async () => { const { data } = await api.auth.login.post({ email: 'test@example.com', password: 'SecurePass123!', }) expect(data?.accessToken).toBeDefined() }) })
// On your frontend (Next.js, Nuxt, etc.) import { treaty } from '@elysiajs/eden' import type { App } from '../server/index' // import type only const api = treaty<App>('http://localhost:3000') // Fully typed — no codegen, no OpenAPI spec // TypeScript infers request shape + response const login = async () => { const { data, error } = await api.auth.login.post({ email: 'user@example.com', password: 'password', }) // data is typed: { accessToken: string, user: PublicUser } // error is typed: { status: 401 | 422, message: string } if (data) console.log(data.accessToken) } // Typed product fetch with query params const getProducts = async () => { const { data } = await api.products.get({ query: { page: 1, limit: 20, search: 'widget' } }) // data is typed: Paginated<Product> return data?.data ?? [] } // Typed WebSocket const chat = api.ws.chat['room-1'].subscribe({ query: { token: accessToken } }) chat.on('message', ({ data }) => { // data is typed from ws body schema console.log(data.text) })
import { defineConfig } from 'drizzle-kit' export default defineConfig({ schema: './src/infrastructure/database/schema.ts', out: './src/infrastructure/database/migrations', dialect: 'sqlite', // or 'postgresql' dbCredentials: { url: process.env.DATABASE_URL!, }, verbose: true, strict: true, }) // package.json scripts: // "db:generate" : "drizzle-kit generate" // "db:migrate" : "drizzle-kit migrate" // "db:studio" : "drizzle-kit studio" // "db:push" : "drizzle-kit push" (dev only) // Migration at startup (production) import { migrate } from 'drizzle-orm/bun-sqlite/migrator' import { db } from './db' export async function runMigrations() { migrate(db, { migrationsFolder: './src/infrastructure/database/migrations' }) console.log('✓ Migrations applied') }
typeof app from your server and import it as a type on the client. TypeScript infers every route, every request shape, every response shape, and every error — zero codegen, zero OpenAPI, zero schema drift. If you rename a route on the server, the client breaks at compile time.
04 — Data Flows
EVERY REQUEST TRACED
From HTTP wire to database row and back. Every middleware, guard, and transform in order.
Authenticated request — full stack
Login flow
Cache-aside pattern
WebSocket message broadcast
Error path — AppError cascade
Token refresh — silent rotation
05 — Feature Matrix
EVERY REQUIREMENT COVERED
Backend equivalents of the original brief — mapped to Bun + Elysia's idiomatic solutions.
elysia — built-in
@elysiajs/jwt
drizzle-orm
drizzle-kit
@sinclair/typebox — built-in
Bun Web Crypto API — built-in
Bun.password — built-in
elysia-rate-limit
ioredis
ioredis
Bun WebSocket — built-in
@elysiajs/eden
@elysiajs/swagger
bun:test — built-in
06 — Package Registry
THE PACKAGE REGISTRY
Lean dependency list. Bun's built-ins replace entire NPM packages — no bcrypt, no node-crypto, no ws library, no test runner.
| Package | Version | Purpose | Layer |
|---|---|---|---|
elysia | ^1.2 | Core HTTP framework — routing, plugins, middleware, WebSocket, TypeBox validation | route |
@elysiajs/jwt | ^1.2 | JWT sign/verify with secret rotation, integrated with Elysia context | route |
@elysiajs/cors | ^1.2 | CORS middleware — origin allowlist, credentials, preflight | route |
@elysiajs/swagger | ^1.2 | Auto-generated OpenAPI 3.0 spec + Swagger UI from TypeBox schemas | route |
@elysiajs/eden | ^1.2 | Eden Treaty — end-to-end type-safe client from typeof app | e2e |
elysia-rate-limit | ^4.x | Rate limiting plugin — sliding window, Redis storage, per-route config | route |
drizzle-orm | ^0.41 | Type-safe SQL ORM — SQLite + PostgreSQL + MySQL, schema inference | data |
drizzle-kit | ^0.30 | Migration generator, schema push, Drizzle Studio GUI | data |
ioredis | ^5.4 | Redis client — cache-aside, pub/sub for SSE, rate limit storage | infra |
pino | ^9.x | Fastest Node/Bun JSON logger — structured fields, log levels, redaction | infra |
pino-pretty | ^13.x | Human-readable pino output for development | infra |
postgres | ^3.4 | PostgreSQL driver for production (swap for Drizzle bun-sqlite in dev) | data |
@sinclair/typebox | built-in | JSON Schema + TypeScript inference — bundled with Elysia, no install needed | types |
typescript | ^5.7 | TypeScript compiler — Bun runs TS natively, tsc only for type checking | dev |
@types/bun | latest | Bun runtime type definitions | dev |
| REPLACED BY BUN BUILT-INS: bcrypt/argon2 → Bun.password · ws/socket.io → Bun.serve WS · better-sqlite3 → bun:sqlite · jest/vitest → bun:test · node-crypto → Web Crypto API · dotenv → Bun.env | |||
✦ — AI Scaffold
Generate with AI
Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.
You are a senior backend engineer. Scaffold a production-ready REST API using Bun + Elysia. Stack: - Runtime: Bun 1.x - Framework: Elysia with Eden Treaty client - Validation: Elysia type system (TypeBox) - Auth: JWT plugin + bearer auth - Database: PostgreSQL via Drizzle ORM (bun:sql) - Cache: Redis (ioredis) - Queue: BullMQ - Logging: pino - Testing: Bun test + @elysiajs/swagger for contract tests - Containerization: Docker + docker-compose Provide: 1. Project structure (src/routes, src/services, src/db, src/middleware) 2. Elysia app bootstrap with plugins (cors, jwt, swagger, bearer) 3. Route group pattern with input validation schemas 4. Drizzle schema definition + seed script 5. Middleware: auth guard, request logger, error handler 6. BullMQ worker + queue definition pattern 7. Docker multi-stage build (dev → prod) 8. Integration test example using Elysia's .handle()