Elysia Architecture
Purpose
Structure Elysia.js applications with plugin-based architecture — lifecycle hooks, type-safe schema, Eden Treaty client generation, and production middleware configuration.
Agent Protocol
Trigger
User request includes: elysia app structure, elysia project layout, elysia plugin, elysia lifecycle, elysia architecture, elysia folder structure, elysia config.
Input Context
- Bun version (1.0+)
- Elysia version (1.x)
- Plugins used (cors, swagger, websocket, auth)
- Database (Bun SQLite, Drizzle, Prisma)
Output Artifact
Project structure, plugin layout, lifecycle hook order, configuration, Eden Treaty client setup.
Response Format
Produce artifact directly. No preamble, no postamble, no explanations. No filler, no hedging.
Completion Criteria
- Plugin-based architecture with clear separation
- Lifecycle hooks in correct order
- Type-safe schema with Elysia t.policy
- Eden Treaty client generated for frontend
- Environment config with Elysia config plugin
Max Response Length
4096 tokens
Architecture Decision Trees
Plugin Architecture vs Monolithic
| Criterion | Plugin Architecture | Monolithic |
|---|---|---|
| Feature count | 5+ features | 1-3 features |
| Team size | 2+ developers | Solo |
| Reuse | Multiple apps share plugins | Single app |
| Testing | Isolated per plugin | End-to-end |
| Bundle size | Lazy-loaded plugins | Single bundle |
Decision: Multi-feature app or shared modules → Plugin Architecture. Simple CRUD → Monolithic.
Lifecycle Hook Strategy
| Hook | Purpose | Use Case |
|---|---|---|
state |
Initialize app state | DB connections, config |
derive |
Compute derived state | Request-local cache |
resolve |
Resolve dependencies | Auth user from token |
onRequest |
Pre-handler | Logging, CORS, rate limit |
beforeHandle |
Pre-validation | Auth check, permissions |
afterHandle |
Post-handler | Transform response, caching |
onError |
Error handler | Global error mapping |
onResponse |
After response sent | Metrics, cleanup |
Order matters: state → derive → resolve → onRequest → beforeHandle → handler → afterHandle → onError/onResponse.
Workflow
Step 1: Project Structure
src/
index.ts # Entry point — create app, register plugins
app.ts # Elysia app factory
config/
env.ts # Environment config
database.ts # DB connection
plugins/
auth.ts # Auth plugin (resolve user, guard)
cors.ts # CORS config plugin
swagger.ts # Swagger/OpenAPI plugin
error-handler.ts # Global error handler
modules/
users/
index.ts # User module plugin
routes.ts # User routes
schema.ts # Validation schemas
service.ts # Business logic
repository.ts # Data access
orders/
index.ts
routes.ts
schema.ts
service.ts
shared/
response.ts # Response helpers
logger.ts # Logger setup
pagination.ts # Pagination types
Step 2: App Factory
// src/app.ts
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { swagger } from '@elysiajs/swagger'
import { authPlugin } from './plugins/auth'
import { errorHandlerPlugin } from './plugins/error-handler'
import { userModule } from './modules/users'
import { orderModule } from './modules/orders'
export const createApp = () => new Elysia()
.use(cors({ origin: Bun.env.CORS_ORIGIN }))
.use(swagger({ path: '/docs' }))
.use(errorHandlerPlugin)
.use(authPlugin)
.use(userModule)
.use(orderModule)
.get('/health', () => ({ status: 'ok', uptime: process.uptime() }))
// src/index.ts
import { createApp } from './app'
const app = createApp()
const port = parseInt(Bun.env.PORT || '3000')
app.listen(port, () => console.log(`Server running on port ${port}`))
Step 3: Auth Plugin (Resolver Pattern)
// src/plugins/auth.ts
import { Elysia, t } from 'elysia'
import { jwt } from '@elysiajs/jwt'
export const authPlugin = new Elysia({ name: 'auth' })
.use(jwt({ secret: Bun.env.JWT_SECRET! }))
.derive({ as: 'scoped' }, async ({ jwt, headers }) => {
const token = headers.authorization?.slice(7)
if (!token) return { user: null }
const payload = await jwt.verify(token)
return { user: payload as { id: string; role: string } | null }
})
.macro({
// @ts-ignore
isAuthenticated: {
beforeHandle({ user, error }) {
if (!user) return error(401, { message: 'Unauthorized' })
}
},
hasRole: (roles: string[]) => ({
beforeHandle({ user, error }) {
if (!user || !roles.includes(user.role))
return error(403, { message: 'Forbidden' })
}
})
})
// Usage in routes
app
.guard({ isAuthenticated: true })
.get('/users/me', ({ user }) => user)
.guard({ hasRole: ['admin'] })
.delete('/users/:id', ({ params }) => deleteUser(params.id))
Step 4: Module Pattern
// src/modules/users/schema.ts
import { t } from 'elysia'
export const CreateUserSchema = t.Object({
name: t.String({ minLength: 2 }),
email: t.String({ format: 'email' }),
role: t.Optional(t.Union([t.Literal('admin'), t.Literal('user')])),
})
export const UserResponseSchema = t.Object({
id: t.String(),
name: t.String(),
email: t.String(),
role: t.String(),
})
export const UserParamsSchema = t.Object({
id: t.String(),
})
// src/modules/users/routes.ts
import { Elysia, t } from 'elysia'
import { CreateUserSchema, UserResponseSchema, UserParamsSchema } from './schema'
import * as userService from './service'
export const userRoutes = new Elysia({ prefix: '/users' })
.get('/', async ({ query }) => {
const users = await userService.findAll(query)
return { data: users }
}, {
query: t.Object({ page: t.Optional(t.Numeric()), limit: t.Optional(t.Numeric()) }),
})
.get('/:id', async ({ params }) => {
const user = await userService.findById(params.id)
if (!user) return { error: 'Not found', status: 404 }
return { data: user }
}, {
params: UserParamsSchema,
response: t.Object({ data: UserResponseSchema }),
})
.post('/', async ({ body }) => {
const user = await userService.create(body)
return { data: user }
}, {
body: CreateUserSchema,
response: t.Object({ data: UserResponseSchema }),
status: 201,
})
// src/modules/users/index.ts
import { Elysia } from 'elysia'
import { userRoutes } from './routes'
export const userModule = new Elysia().use(userRoutes)
Step 5: Error Handler Plugin
// src/plugins/error-handler.ts
import { Elysia } from 'elysia'
export const errorHandlerPlugin = new Elysia({ name: 'error-handler' })
.onError({ as: 'global' }, ({ code, error, set }) => {
console.error(`[${code}] ${error.message}`)
switch (code) {
case 'VALIDATION':
set.status = 400
return { success: false, error: { code: 'VALIDATION_ERROR', message: error.message } }
case 'NOT_FOUND':
set.status = 404
return { success: false, error: { code: 'NOT_FOUND', message: 'Resource not found' } }
case 'INTERNAL_SERVER_ERROR':
set.status = 500
return { success: false, error: { code: 'INTERNAL_ERROR', message: 'Unexpected error' } }
default:
set.status = 500
return { success: false, error: { code: 'UNKNOWN', message: error.message } }
}
})
Step 6: Environment Config
// src/config/env.ts
import { t } from 'elysia'
export const envSchema = t.Object({
PORT: t.Optional(t.String()),
NODE_ENV: t.Optional(t.Union([t.Literal('development'), t.Literal('production'), t.Literal('test')])),
DATABASE_URL: t.String(),
JWT_SECRET: t.String({ minLength: 32 }),
CORS_ORIGIN: t.Optional(t.String()),
LOG_LEVEL: t.Optional(t.String()),
})
export type Env = typeof envSchema.static
// Validate on startup
const envCheck = envSchema.safeParse(Bun.env)
if (!envCheck.success) {
console.error('Invalid environment:', envCheck.error)
process.exit(1)
}
Step 7: Eden Treaty Client
// client/eden.ts (separate frontend package)
import { edenTreaty } from '@elysiajs/eden'
import type { App } from '../server/src/app'
export const client = edenTreaty<App>('http://localhost:3000')
// Usage
const { data, error } = await client.users({ headers: { authorization: `Bearer ${token}` }}).get()
if (error) console.error(error)
else console.log(data)
Implementation Patterns
Pattern: Scoped State per Request
const app = new Elysia()
.state('db', dbConnection)
.derive({ as: 'scoped' }, ({ request }) => ({
requestId: crypto.randomUUID(),
}))
.onRequest(({ request, store, requestId }) => {
console.log(`[${requestId}] ${request.method} ${request.url}`)
})
Pattern: Type-Safe Response
const app = new Elysia()
.get('/orders/:id', ({ params }) => {
return {
id: params.id,
items: [],
total: 0
}
}, {
response: t.Object({
id: t.String(),
items: t.Array(t.Object({
product: t.String(),
price: t.Number(),
qty: t.Number(),
})),
total: t.Number(),
})
})
Production Considerations
Performance
- Elysia uses Bun's HTTP server (native, fast) — no
node:httpoverhead - Keep handlers async to leverage Bun's I/O multiplexing
- Use
t.Objectvalidation — compile-time schemas, zero runtime overhead - Avoid
resolvefor every request — preferderivefor per-request data - Static files via
@elysiajs/staticplugin (Bun.file for custom serving)
CORS Configuration
app.use(cors({
origin: ['https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
}))
Rate Limiting
// Manual rate limiter as derive
app.derive({ as: 'scoped' }, ({ headers, set }) => {
const ip = headers['x-forwarded-for'] || 'unknown'
const key = `rate:${ip}`
const count = parseInt(Bun.env[key] || '0')
if (count > 100) {
set.status = 429
return { rateLimited: true }
}
Bun.env[key] = String(count + 1)
return { rateLimited: false }
})
Anti-Patterns
| Anti-Pattern | Why | Fix |
|---|---|---|
| Global state mutation | Race conditions | Use scoped derive or resolve |
| Inline schema everywhere | Duplication, hard to maintain | Shared schema files per module |
Missing onError handler |
Raw stack traces to client | Global error handler plugin |
Direct Bun.env access |
Untyped, missing defaults | Validated env config on startup |
No as: 'scoped' on derive |
State leaks between requests | Always scope per-request data |
| Heavy logic in guards | Routes hard to test | Extract to service layer |
Security Considerations
- Elysia's
t.Objectwithformat: 'email'andminLengthfor input validation - JWT via
@elysiajs/jwt— algorithm defaults toHS256, nevernone - Rate limiting at proxy/reverse proxy level for production (nginx, cloudflare)
- CORS with explicit origins — never
*in production - Helmet-like headers:
X-Content-Type-Options,X-Frame-Options,Strict-Transport-Security - Auth plugin uses resolve/derive — tokens verified before handler runs
Testing Strategies
Unit Testing Services
import { describe, expect, test } from 'bun:test'
test('user service creates valid user', async () => {
const user = await userService.create({ name: 'Test', email: 'test@test.com' })
expect(user.id).toBeString()
expect(user.email).toBe('test@test.com')
})
Integration Testing with Elysia
import { describe, expect, test } from 'bun:test'
import { createApp } from '../src/app'
const app = createApp()
test('POST /users creates user', async () => {
const res = await app.handle(
new Request('http://localhost/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test', email: 'test@test.com' }),
})
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.name).toBe('Test')
})
test('GET /users/:id returns 404', async () => {
const res = await app.handle(new Request('http://localhost/users/nonexistent'))
expect(res.status).toBe(404)
})
Rules
- Plugins registered before modules. Global plugins (cors, swagger, error handler) before domain plugins.
- Each module is an Elysia instance with
{ prefix }— no inline route registration inapp.ts. - Validation schemas defined per module, not globally. Reused via
t.Objectexports. - All environment vars validated at startup — fail fast on missing config.
derivefor per-request data (user, requestId),statefor app-wide data (db, config).- Error handler as global
onError— never try/catch in handlers. - Eden Treaty client generated from server type exports for full-stack type safety.
References
- references/elysia-custom-plugins.md — Custom Plugin Development
- references/elysia-deployment.md — Deployment Guide
- references/elysia-lifecycle.md — Lifecycle Hooks
- references/elysia-plugins-ecosystem.md — Plugin Ecosystem
- references/elysia-plugins.md — Elysia Plugins
- references/elysia-routing-validation.md — Routing and Validation
- references/elysia-testing.md — Testing Patterns
- references/elysia-type-safety-patterns.md — Type Safety with Eden
Handoff
Hand off to backend/elysia/patterns/SKILL.md for Elysia-specific patterns or backend/universal/api-response/SKILL.md for API response formatting.
Implementation Patterns
Factory Pattern for Module Creation
function createModule<T>(config: ModuleConfig): T { const dependencies = initializeDependencies(config); const module = new Module(dependencies); module.hooks.onInit(); return module as T; }
Builder Pattern for Complex Configuration
class ConfigBuilder { private config: AppConfig = new AppConfig(); withDatabase(url: string): ConfigBuilder { ... } withCache(ttl: number): ConfigBuilder { ... } withLogging(level: string): ConfigBuilder { ... } build(): AppConfig { return this.config; } }
Production Considerations
Deployment Checklist
- Production build with optimizations enabled
- Environment variables configured per environment
- Health check endpoint responds correctly
- Error tracking and monitoring integrated
- Logging level configured (not debug in production)
- Resource limits configured
- Database migrations applied
- Static assets built and served from CDN or cache
- Feature flags toggled appropriately
- Rollback plan documented and tested
Monitoring and Alerting
| Metric | Threshold | Severity | Action |
|---|---|---|---|
| Error rate | > 1% | Critical | Rollback or fix |
| p95 latency | > 500ms | Warning | Profile and optimize |
| Uptime | < 99.9% | Critical | Investigate infrastructure |
| Memory usage | > 80% | Warning | Check for leaks |
| CPU usage | > 80% | Warning | Scale up or optimize |
Rules
- Prefer composition over inheritance
- Favor immutable data structures
- Use dependency injection for testability
- Keep functions pure when possible — no side effects
- Fail fast with clear error messages
- Don't repeat yourself (DRY) — extract shared logic
- Keep it simple (KISS) — avoid unnecessary complexity
- You aren't gonna need it (YAGNI) — build what's required
- Separate concerns — single responsibility per module
- Code to interfaces, not implementations
- Write self-documenting code — clear names over comments
- Prefer standard library over third-party dependencies
- Handle errors explicitly — no silent failures
- Validate inputs at boundaries
- Log at appropriate levels (debug, info, warn, error)