Backend Engineering · TypeScript · Bun runtime

Backend Architecture Bun · ElysiaJS · Drizzle · TypeScript

End-to-end type-safe, plugin-composed, domain-driven backend architecture — built for the speed of Bun and the elegance of Elysia's schema-first design.

5× faster than Node Eden E2E types Drizzle ORM JWT + RBAC WebSocket native Bun SQLite AES-256 encrypt Rate limiting
HTTP / WS Layer Elysia routes · plugins · guards · WebSocket Entry
↓ calls ↓
Service Layer Business logic · orchestration · validation Logic
↓ calls ↓
Domain Layer Entities · use cases · repository interfaces Core
↓ implemented by ↓
Repository Layer Drizzle · Bun SQLite · Redis · S3 Data
↓ wired by ↓
Infrastructure DI container · config · logger · crypto Core

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.

The Elysia mental model: Every feature is a plugin. Plugins compose into the root app. Each plugin owns its routes, schema validation (TypeBox), and guards. Business logic lives in services, data access in repositories. Nothing leaks across.
H
HTTP / WS Layer
Elysia route handlers and WebSocket handlers. Define request/response schema with TypeBox — Elysia validates and infers types automatically. Guards (JWT, RBAC) applied per-plugin. Zero business logic here.
S
Service Layer
Pure TypeScript classes. Orchestrate repository calls, enforce business rules, transform data, emit events. No HTTP concerns. Injected via Elysia's decorate() or a lightweight DI container.
D
Domain Layer
Entities as plain TypeScript types/classes. Repository interfaces defined here. Use cases for complex flows. No database imports. Pure business concepts — the heart of the application.
R
Repository Layer
Implements domain interfaces using Drizzle ORM over Bun's built-in SQLite (or PostgreSQL via postgres.js). Maps database rows to domain entities. Handles caching via Redis where needed.
I
Infrastructure
Config loading (env validation), logger (pino), crypto utilities (AES-256), mailer, S3 client, and the DI container. Shared across all layers. Initialized once at startup, injected everywhere.
P
Plugin System
Elysia plugins are the unit of composition. Each feature (auth, products, users, payments) is an isolated plugin. Plugins declare their own schema, guards, and routes. The root app is just a list of plugins.
Requests / second
~180k
Bun + Elysia, hello world benchmark
Cold start time
< 15ms
Bun runtime vs ~200ms Node.js
Type safety coverage
100%
Eden Treaty infers client types from server schema
Bundled test runner
bun test
Zero config — Jest-compatible API, 5× faster

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.

src /
features/— one dir per domain feature
auth/
auth.plugin.tsPlugin— Elysia plugin, routes, guards
auth.service.ts— login, register, token refresh
auth.repository.ts— Drizzle user queries
auth.schema.ts— TypeBox request/response schemas
auth.test.tsTest
products/— same structure, every feature
products.plugin.tsPlugin
products.service.ts
products.repository.ts
products.schema.ts
domain/Domain
user.entity.ts— pure TS type, no DB imports
product.entity.ts
repositories/— interfaces (no implementation)
IUserRepository.ts
IProductRepository.ts
errors/
AppError.ts— typed error hierarchy
infrastructure/Infra
database/
db.ts— Drizzle client singleton
schema.ts— all Drizzle table definitions
migrations/— drizzle-kit generated SQL
crypto/
CryptoService.ts— AES-256-GCM using Bun built-ins
config.ts— env validation with TypeBox
logger.ts— pino structured logger
redis.ts— ioredis client + cache helpers
plugins/Shared Plugins
jwt.plugin.ts— @elysiajs/jwt + guard macro
cors.plugin.ts
rateLimit.plugin.ts— elysia-rate-limit
errorHandler.plugin.ts— global onError + AppError map
swagger.plugin.ts— @elysiajs/swagger auto-docs
index.ts— root Elysia app, composes all plugins
drizzle.config.ts— migration config
bunfig.toml— Bun runtime config

03 — Code Patterns

TYPESCRIPT BLUEPRINTS

Production-ready TypeScript. Fully typed end-to-end — from Drizzle schema to Elysia response to the Eden Treaty client.

index.ts — root app compositionentry
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
auth.plugin.ts — feature pluginroute
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)
  })
products.plugin.ts — CRUD with guardroute
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))
  )
errorHandler.plugin.ts — global errorsplugin
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' }
  })
user.entity.ts — pure domain typedomain
// 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'>
IUserRepository.ts — interfacedomain
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
}
auth.service.ts — business logicservice
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 })
  }
}
AppError.ts — typed error hierarchydomain
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')
  }
}
schema.ts — Drizzle table definitionsinfra · db
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
db.ts — Drizzle + Bun SQLite clientinfra · db
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))
user.repository.ts — Drizzle implrepo
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) }
  }
}
redis.ts — cache layerinfra
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))
jwt.plugin.ts — shared JWT pluginplugin
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')
      })
    },
  }))
rateLimit.plugin.tsplugin
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 }))
  )
Using RBAC macro in a routeusage
// 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,
})
CryptoService.ts — AES-256-GCM via Buninfra · crypto
// 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)
  }
}
config.ts — env validation at startupinfra
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
chat.plugin.ts — Elysia WebSocketroute · ws
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() }),
  })
SSE — Server-Sent Events streamroute
// 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 })
logger.ts — pino structured logginginfra
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')
  })
auth.test.ts — bun testtest
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()
  })
})
Eden Treaty — end-to-end type safetyclient · types
// 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)
})
drizzle.config.ts + migration workflowinfra · db
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')
}
Eden Treaty's superpower: You export 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

HTTP request
Rate limit check
JWT verify + derive(user)
Route handler
Service.method()
Repository.query()
Drizzle → SQLite

Login flow

POST /auth/login
TypeBox validation
AuthService.login()
findByEmail()
Bun.password.verify()
JWT pair + httpOnly cookie

Cache-aside pattern

GET /products/:id
ProductService.get(id)
Redis.get(key)
→ hit →
return cached
Redis miss
Drizzle query
Redis.setex(300s)
return fresh

WebSocket message broadcast

ws.message()
TypeBox validate body
ws.publish(room, data)
Bun pub/sub → all subscribers

Error path — AppError cascade

Service throws NotFoundError
Elysia onError handler
instanceof AppError → map statusCode
{ success: false, code, message }

Token refresh — silent rotation

401 from client
POST /auth/refresh
verify httpOnly cookie
TokenService.generatePair()
new access + rotated refresh

05 — Feature Matrix

EVERY REQUIREMENT COVERED

Backend equivalents of the original brief — mapped to Bun + Elysia's idiomatic solutions.

Plugin composition
Every feature is an isolated Elysia plugin. Plugins compose into the root app via .use(). Each owns routes, schema, guards. No feature reaches into another's internals.
elysia — built-in
🔐
JWT + RBAC auth
@elysiajs/jwt for signing/verifying. Access token (15m) + refresh token (7d) in httpOnly cookie. RBAC via Elysia macros — requireRole(['admin']) is a single decorator on any route.
@elysiajs/jwt
🗄️
Drizzle ORM
Type-safe SQL. Schema defines table structure — types inferred automatically. Bun SQLite (dev/small prod) or postgres.js (large prod) — swap the driver, keep the same query code.
drizzle-orm drizzle-kit
📋
TypeBox schema validation
TypeBox is Elysia's built-in schema system. Define schemas with t.Object() — Elysia validates at runtime AND TypeScript infers types statically. Zero Zod or Yup needed.
@sinclair/typebox — built-in
🔒
AES-256-GCM encryption
Bun exposes Web Crypto API natively — no node:crypto, no external library. AES-256-GCM with random 12-byte IV per operation. Used for field-level encryption of sensitive DB columns.
Bun Web Crypto API — built-in
🛡️
Password hashing
Bun.password.hash() uses Argon2id by default — the strongest modern password KDF. Bun.password.verify() for checking. Zero bcrypt dependency. Built into the runtime.
Bun.password — built-in
⏱️
Rate limiting
elysia-rate-limit with Redis storage for distributed limiting. Per-route configuration — stricter limits on auth endpoints. IP-based by default, extendable to user-based.
elysia-rate-limit ioredis
💾
Redis cache
Generic cached() helper wraps any async function. Cache-aside pattern — miss → DB fetch → cache write → return. TTL per resource type. Invalidation by key prefix on mutations.
ioredis
WebSocket + SSE
Bun has native WebSocket with pub/sub via ws.publish(room, data). Elysia wraps it with typed body schema. SSE for unidirectional streams — lower overhead for notifications.
Bun WebSocket — built-in
🔗
Eden Treaty E2E types
Export typeof app from the server, import as a type on the client. Eden Treaty creates a fully typed RPC-style client. Route renames break the client at compile time. Zero codegen step.
@elysiajs/eden
📖
Auto-generated API docs
@elysiajs/swagger generates OpenAPI spec and Swagger UI at /docs automatically from your TypeBox schemas. No manual annotation. Tag routes by feature with the detail option.
@elysiajs/swagger
🧪
Testing — bun test
Bun's built-in test runner is Jest-compatible with zero config. Eden Treaty creates a typed test client — call api.auth.login.post() with type checking. 5× faster than Jest.
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.

Note on Bun built-ins: Bun.password (Argon2id), Bun.serve (HTTP + WebSocket), bun:sqlite, Web Crypto API, and bun:test are all zero-dependency built-ins. Compare to the Node.js equivalent: argon2, ws, better-sqlite3, jest — four extra packages gone.
PackageVersionPurposeLayer
elysia^1.2Core HTTP framework — routing, plugins, middleware, WebSocket, TypeBox validationroute
@elysiajs/jwt^1.2JWT sign/verify with secret rotation, integrated with Elysia contextroute
@elysiajs/cors^1.2CORS middleware — origin allowlist, credentials, preflightroute
@elysiajs/swagger^1.2Auto-generated OpenAPI 3.0 spec + Swagger UI from TypeBox schemasroute
@elysiajs/eden^1.2Eden Treaty — end-to-end type-safe client from typeof appe2e
elysia-rate-limit^4.xRate limiting plugin — sliding window, Redis storage, per-route configroute
drizzle-orm^0.41Type-safe SQL ORM — SQLite + PostgreSQL + MySQL, schema inferencedata
drizzle-kit^0.30Migration generator, schema push, Drizzle Studio GUIdata
ioredis^5.4Redis client — cache-aside, pub/sub for SSE, rate limit storageinfra
pino^9.xFastest Node/Bun JSON logger — structured fields, log levels, redactioninfra
pino-pretty^13.xHuman-readable pino output for developmentinfra
postgres^3.4PostgreSQL driver for production (swap for Drizzle bun-sqlite in dev)data
@sinclair/typeboxbuilt-inJSON Schema + TypeScript inference — bundled with Elysia, no install neededtypes
typescript^5.7TypeScript compiler — Bun runs TS natively, tsc only for type checkingdev
@types/bunlatestBun runtime type definitionsdev
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.

 Architecture Prompt
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()
crafted with by Sam
 Copied to clipboard!