46 KiB
Hono Framework Developer Guide for AI Agents
This document provides a comprehensive guide to the Hono-based enterprise framework for AI coding assistants. This framework is NestJS-inspired with Hono performance, featuring decorators, dependency injection, and a modular architecture.
📋 Table of Contents
- Framework Overview
- Core Concepts
- Architecture Patterns
- Decorators Reference
- Request Pipeline
- Dependency Injection
- Common Implementation Patterns
- Testing Strategy
Framework Overview
What is This Framework?
This is a custom web framework built on top of Hono that provides:
- Decorator-based routing (similar to NestJS)
- Dependency injection via
tsyringe - Request-scoped context using
AsyncLocalStorage - Extensible enhancers (Guards, Pipes, Interceptors, Filters)
- Type-safe validation with Zod
- Lifecycle hooks for startup/shutdown management
Key Framework Packages
| Package | Purpose |
|---|---|
@afilmory/framework |
Core framework with decorators, DI, HTTP context, logger, etc. |
@afilmory/db |
Drizzle ORM schema and migrations |
@afilmory/env |
Runtime environment validation |
@afilmory/redis |
Redis client factory with strong typing |
@afilmory/task-queue |
Task queue implementation with in-memory and Redis drivers |
@afilmory/websocket |
WebSocket gateway with Redis pub/sub |
Core Concepts
1. Modules
Modules are the fundamental building blocks that organize your application into cohesive feature sets.
Module Structure:
import { Module } from '@afilmory/framework'
@Module({
imports: [OtherModule], // Import other modules
controllers: [UserController], // HTTP endpoints
providers: [UserService], // Injectable services
})
export class UserModule {}
Key Points:
- Modules are singletons - only instantiated once
imports- Include other modules to access their exported providerscontrollers- Define HTTP route handlersproviders- Services, repositories, utilities available for DI- Use
forwardRef(() => Module)for circular dependencies
2. Controllers
Controllers handle HTTP requests and define routes using decorators.
Basic Controller:
import { Controller, Get, Post, Body, Param, Query } from '@afilmory/framework'
@Controller('users') // Base path: /users
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('/')
async findAll(@Query('limit') limit?: string) {
return this.userService.findAll(Number(limit) || 10)
}
@Get('/:id')
async findOne(@Param('id') id: string) {
return this.userService.findById(id)
}
@Post('/')
async create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto)
}
}
Key Points:
- Controllers must have
@Controller(prefix)decorator - The
prefixis the base path for all routes in the controller - Route methods use HTTP decorators:
@Get(),@Post(),@Put(),@Patch(),@Delete() - Constructor injection automatically resolves dependencies
3. Providers (Services)
Providers are injectable classes that contain business logic.
import { injectable } from 'tsyringe'
@injectable()
export class UserService {
constructor(
private readonly dbAccessor: DbAccessor,
private readonly redis: RedisAccessor,
) {}
async findById(id: string) {
const db = this.dbAccessor.get()
return db.query.users.findFirst({
where: eq(schema.users.id, id),
})
}
async create(data: CreateUserInput) {
const db = this.dbAccessor.get()
const [user] = await db.insert(schema.users).values(data).returning()
return user
}
}
Key Points:
- Providers must have
@injectable()decorator fromtsyringe - Registered in module's
providersarray - Use constructor injection for dependencies
- Should contain reusable business logic
4. Request Context (HttpContext)
The framework provides a request-scoped context using Node's AsyncLocalStorage.
Accessing Context:
import { HttpContext } from '@afilmory/framework'
// In any service, guard, interceptor, or pipe
@injectable()
export class AuditService {
logRequest() {
const honoContext = HttpContext.getValue('hono')
const path = honoContext.req.path
const method = honoContext.req.method
console.log(`Request: ${method} ${path}`)
}
}
// Or get the entire context
const context = HttpContext.get()
const honoContext = context.hono
Setting Custom Values:
// Extend the context type
declare module '@afilmory/framework' {
interface HttpContextValues {
userId?: string
requestId?: string
}
}
// In a guard or interceptor
HttpContext.setValue('userId', '123')
HttpContext.assign({ userId: '123', requestId: 'abc' })
Key Points:
- Context is automatically managed per request
- Available in guards, pipes, interceptors, filters, and services
- Use
HttpContext.getValue('hono')to access Hono'sContext - Can be extended with custom properties via module augmentation
Architecture Patterns
Application Bootstrap
Standard Bootstrap Pattern:
import 'reflect-metadata'
import { serve } from '@hono/node-server'
import { createApplication } from '@afilmory/framework'
async function bootstrap() {
// Create the application
const app = await createApplication(AppModule, {
globalPrefix: '/api', // Optional: all routes prefixed with /api
})
// Register global enhancers
app.useGlobalPipes(ValidationPipe)
app.useGlobalGuards(AuthGuard)
app.useGlobalInterceptors(LoggingInterceptor)
app.useGlobalFilters(AllExceptionsFilter)
// Get the underlying Hono instance
const hono = app.getInstance()
// Start the server
serve({
fetch: hono.fetch,
port: 3000,
hostname: '0.0.0.0',
})
}
bootstrap()
Key Points:
reflect-metadatamust be imported at the topcreateApplicationis async and returnsHonoHttpApplication- Global enhancers apply to all routes
- Access Hono instance via
app.getInstance()for middleware
Module Organization
Root Module Pattern:
import { Module } from '@afilmory/framework'
import { DatabaseModule } from './database/database.module'
import { RedisModule } from './redis/redis.module'
import { UserModule } from './modules/user/user.module'
import { AuthModule } from './modules/auth/auth.module'
@Module({
imports: [
DatabaseModule, // Infrastructure modules first
RedisModule,
UserModule, // Feature modules
AuthModule,
],
})
export class AppModule {}
Key Points:
- Root module typically has no controllers/providers
- Import infrastructure modules (DB, Redis) first
- Feature modules come after infrastructure
- Each feature should be self-contained
Infrastructure Modules (Database & Redis)
Database Module Pattern:
import { Module } from '@afilmory/framework'
import { DbAccessor } from './database.provider'
@Module({
providers: [DbAccessor],
})
export class DatabaseModule {}
// Provider
@injectable()
export class DbAccessor {
private db: ReturnType<typeof drizzle> | null = null
constructor() {
// Initialize connection pool
const pool = new Pool({ connectionString: env.DATABASE_URL })
this.db = drizzle(pool, { schema })
}
get() {
if (!this.db) {
throw new Error('Database not initialized')
}
return this.db
}
}
Redis Module Pattern:
import { Module } from '@afilmory/framework'
import { RedisAccessor } from './redis.provider'
@Module({
providers: [RedisAccessor],
})
export class RedisModule {}
// Provider
@injectable()
export class RedisAccessor {
private client: Redis
constructor() {
this.client = new Redis(env.REDIS_URL)
}
get(): Redis {
return this.client
}
}
Key Points:
- Infrastructure providers use accessor pattern with
.get()method - There aren't
exportsin module, this is different from NestJS - Initialize connections in constructor
- Implement lifecycle hooks (
OnModuleDestroy) for cleanup
Decorators Reference
Module Decorators
// Define a module
@Module({
imports: [FeatureModule], // Other modules to import
controllers: [MyController], // HTTP endpoints
providers: [MyService], // Injectable services
})
export class MyModule {}
// Forward reference for circular dependencies
@Module({
imports: [forwardRef(() => CircularModule)],
})
export class MyModule {}
Controller & Route Decorators
// Controller base path
@Controller('api/v1/users')
export class UserController {}
// HTTP method decorators
@Get('/path') // GET request
@Post('/path') // POST request
@Put('/path') // PUT request
@Patch('/path') // PATCH request
@Delete('/path') // DELETE request
@Options('/path') // OPTIONS request
@Head('/path') // HEAD request
Parameter Decorators
class MyController {
@Get('/:id')
async handler(
@Param('id') id: string, // Route parameter
@Query('search') search?: string, // Query string parameter
@Body() body: CreateDto, // Request body (auto-parsed JSON)
@Headers('authorization') auth?: string, // Specific header
@Headers() allHeaders: Headers, // All headers
@Req() request: HonoRequest, // Hono request object
@ContextParam() context: Context, // Hono context
context: Context, // Inferred context (if no decorator)
) {
// Handler logic
}
}
Parameter with Pipes:
// Apply pipe to specific parameter
@Get('/:id')
async findOne(@Param('id', ParseIntPipe) id: number) {
// id is now a number (transformed by pipe)
}
// Multiple pipes
@Post('/')
async create(@Body(ValidationPipe, TransformPipe) data: CreateDto) {
// data is validated then transformed
}
Enhancer Decorators
// Guards - Authorization/Authentication
@UseGuards(AuthGuard, RolesGuard)
@Get('/protected')
async protectedRoute() {}
// Pipes - Validation/Transformation
@UsePipes(ValidationPipe, TransformPipe)
@Post('/data')
async create() {}
// Interceptors - Modify request/response
@UseInterceptors(LoggingInterceptor, CacheInterceptor)
@Get('/data')
async getData() {}
// Exception Filters - Error handling
@UseFilters(HttpExceptionFilter, ValidationExceptionFilter)
@Post('/risky')
async riskyOperation() {}
Scope:
- Method level: Apply to specific route handler
- Controller level: Apply to all routes in controller
- Global level: Apply to all routes in application (via
app.useGlobal*())
Validation Decorators (Zod)
import { z } from 'zod'
import { createZodSchemaDto } from '@afilmory/framework'
// Define schema
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().positive().optional(),
})
// Create DTO class
class CreateUserDto extends createZodSchemaDto(CreateUserSchema) {}
// Use in controller
@Controller('users')
export class UserController {
@Post('/')
async create(@Body() data: CreateUserDto) {
// data is validated and typed
}
}
Request Pipeline
Execution Order
When a request hits an endpoint, the framework processes it through these phases:
Request
↓
1. HttpContext.run() - Establish request scope
↓
2. Guards - Check permissions (global → controller → method)
↓
3. Interceptors (before) - Pre-processing (global → controller → method)
↓
4. Pipes - Parameter validation/transformation
↓
5. Controller Handler - Your business logic
↓
6. Interceptors (after) - Post-processing (reverse order)
↓
7. Exception Filters - Error handling (if error thrown)
↓
Response
1. Guards
Guards determine whether a request should be handled by the route.
Guard Implementation:
import { injectable } from 'tsyringe'
import { CanActivate, ExecutionContext, UnauthorizedException, HttpContext } from '@afilmory/framework'
@injectable()
export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const httpContext = context.switchToHttp().getContext()
const honoContext = httpContext.hono
const token = honoContext.req.header('authorization')
if (!token) {
throw new UnauthorizedException('Missing authorization token')
}
// Validate token
const user = await this.validateToken(token)
if (!user) {
return false // Returns 403 Forbidden
}
// Store user in context for later use
HttpContext.assign({ user })
return true
}
private async validateToken(token: string) {
// Token validation logic
}
}
Usage:
@Controller('admin')
@UseGuards(AuthGuard, AdminGuard) // All routes protected
export class AdminController {
@Get('/dashboard')
async getDashboard() {
// Only reached if guards pass
}
@Get('/public')
async getPublic() {
// Still protected by controller-level guards
}
}
Key Points:
- Return
false→ 403 Forbidden (automatic) - Throw exception → Custom error response
- Guards run in order: global → controller → method
- Use for authentication, authorization, rate limiting
2. Pipes
Pipes transform and validate input data.
Pipe Implementation:
import { injectable } from 'tsyringe'
import { PipeTransform, ArgumentMetadata, BadRequestException } from '@afilmory/framework'
@injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed)) {
throw new BadRequestException(`Validation failed: "${value}" is not an integer`)
}
return parsed
}
}
Built-in Validation Pipe:
import { createZodValidationPipe } from '@afilmory/framework'
// Create configured validation pipe
const ValidationPipe = createZodValidationPipe({
transform: true, // Transform to DTO class instances
whitelist: true, // Strip unknown properties
errorHttpStatusCode: 422, // Status code for validation errors
forbidUnknownValues: true, // Reject non-objects for body
stopAtFirstError: false, // Return all validation errors
})
// already registered globally
app.useGlobalPipes(ValidationPipe)
Key Points:
- Pipes run after guards, before handler
- Order: global → method → parameter
- Use for validation, transformation, sanitization
- Parameter pipes run last (most specific)
3. Interceptors
Interceptors wrap the request/response flow and can modify both.
Interceptor Implementation:
import { injectable } from 'tsyringe'
import { Interceptor, ExecutionContext, CallHandler, FrameworkResponse } from '@afilmory/framework'
@injectable()
export class LoggingInterceptor implements Interceptor {
async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
const httpContext = context.switchToHttp().getContext()
const { req } = httpContext.hono
const start = Date.now()
console.log(`→ ${req.method} ${req.path}`)
// Call the handler and subsequent interceptors
const response = await next.handle()
const duration = Date.now() - start
console.log(`← ${req.method} ${req.path} ${duration}ms`)
return response
}
}
Response Transform Interceptor:
@injectable()
export class TransformInterceptor implements Interceptor {
async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
const response = await next.handle()
// Transform response body
const data = await response.clone().json()
return new Response(
JSON.stringify({
success: true,
data,
timestamp: new Date().toISOString(),
}),
{
status: response.status,
headers: response.headers,
},
)
}
}
Key Points:
- Wrap handler execution with
next.handle() - Can modify request before handler
- Can modify response after handler
- Run in order: global → controller → method (then reverse)
- Use for logging, caching, response transformation
4. Exception Filters
Filters catch and handle exceptions thrown during request processing.
Filter Implementation:
import { injectable } from 'tsyringe'
import { ExceptionFilter, ArgumentsHost, HttpException } from '@afilmory/framework'
@injectable()
export class AllExceptionsFilter implements ExceptionFilter {
async catch(exception: Error, host: ArgumentsHost) {
const httpContext = host.switchToHttp().getContext()
const { hono } = httpContext
let status = 500
let message = 'Internal server error'
let details: any = {}
if (exception instanceof HttpException) {
status = exception.getStatus()
const response = exception.getResponse()
if (typeof response === 'object') {
details = response
} else {
message = String(response)
}
} else {
message = exception.message
details.stack = exception.stack
}
return new Response(
JSON.stringify({
statusCode: status,
message,
...details,
path: hono.req.path,
timestamp: new Date().toISOString(),
}),
{
status,
headers: { 'content-type': 'application/json' },
},
)
}
}
Key Points:
- Filters run when exception is thrown
- Can return custom Response or undefined
- If filter returns undefined, next filter runs
- Use for error logging, error formatting, monitoring
Dependency Injection
Basic DI Usage
Service Registration:
// In module
@Module({
providers: [
UserService, // Singleton by default
EmailService,
],
})
export class UserModule {}
Constructor Injection:
@injectable()
export class UserService {
constructor(
private readonly db: DbAccessor,
private readonly cache: RedisAccessor,
private readonly logger: Logger,
) {}
}
Accessing the Container
// In application bootstrap
const app = await createApplication(AppModule)
const container = app.getContainer()
// Manually resolve a provider
const userService = container.resolve(UserService)
Important DI Patterns
❌ Wrong - Import Type:
// This will cause DI errors!
import type { UserService } from './user.service'
@injectable()
export class OrderService {
constructor(private readonly userService: UserService) {}
// ^^^ Type-only import won't work
}
✅ Correct - Import Value:
// Import the actual class
import { UserService } from './user.service'
@injectable()
export class OrderService {
constructor(private readonly userService: UserService) {}
}
Common Implementation Patterns
1. CRUD Controller Pattern
import { z } from 'zod'
import { createZodSchemaDto } from '@afilmory/framework'
// DTOs for request validation
const PaginationQuerySchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).default('1'),
limit: z.string().regex(/^\d+$/).transform(Number).default('10'),
})
const UserIdParamSchema = z.object({
id: z.string().uuid(),
})
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().positive().optional(),
})
const UpdateUserSchema = z.object({
email: z.string().email().optional(),
name: z.string().min(1).max(100).optional(),
age: z.number().int().positive().optional(),
})
class PaginationQueryDto extends createZodSchemaDto(PaginationQuerySchema) {}
class UserIdParamDto extends createZodSchemaDto(UserIdParamSchema) {}
class CreateUserDto extends createZodSchemaDto(CreateUserSchema) {}
class UpdateUserDto extends createZodSchemaDto(UpdateUserSchema) {}
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('/')
async findAll(@Query() query: PaginationQueryDto) {
return this.userService.findAll({
page: query.page,
limit: query.limit,
})
}
@Get('/:id')
async findOne(@Param() params: UserIdParamDto) {
const user = await this.userService.findById(params.id)
if (!user) {
throw new NotFoundException(`User ${params.id} not found`)
}
return user
}
@Post('/')
async create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto)
}
@Patch('/:id')
async update(@Param() params: UserIdParamDto, @Body() updateUserDto: UpdateUserDto) {
return this.userService.update(params.id, updateUserDto)
}
@Delete('/:id')
async remove(@Param() params: UserIdParamDto) {
await this.userService.remove(params.id)
return { deleted: true }
}
}
Key Points:
- Query Parameters: Use
@Query()without parameter name to get all query params, then validate with DTO - Route Parameters: Use
@Param()without parameter name to get all params, then validate with DTO - Schema Transformation: Use
.transform()to convert string query params to numbers - Default Values: Use
.default()for optional query parameters - Validation: All parameters are validated through Zod schemas before reaching the handler
2. Service with Database Pattern
@injectable()
export class UserService {
constructor(private readonly db: DbAccessor) {}
async findAll(options: { page: number; limit: number }) {
const db = this.db.get()
const offset = (options.page - 1) * options.limit
const users = await db.query.users.findMany({
limit: options.limit,
offset,
})
return {
data: users,
page: options.page,
limit: options.limit,
}
}
async findById(id: string) {
const db = this.db.get()
return db.query.users.findFirst({
where: eq(schema.users.id, id),
})
}
async create(data: CreateUserInput) {
const db = this.db.get()
const [user] = await db.insert(schema.users).values(data).returning()
return user
}
async update(id: string, data: UpdateUserInput) {
const db = this.db.get()
const [updated] = await db.update(schema.users).set(data).where(eq(schema.users.id, id)).returning()
return updated
}
async remove(id: string) {
const db = this.db.get()
await db.delete(schema.users).where(eq(schema.users.id, id))
}
}
3. Lifecycle Hooks Pattern
@injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private pool: Pool | null = null
async onModuleInit() {
console.log('Initializing database connection...')
this.pool = new Pool({ connectionString: env.DATABASE_URL })
await this.pool.query('SELECT 1') // Test connection
console.log('Database connected')
}
async onModuleDestroy() {
console.log('Closing database connection...')
await this.pool?.end()
console.log('Database disconnected')
}
getPool(): Pool {
if (!this.pool) {
throw new Error('Database not initialized')
}
return this.pool
}
}
Available Lifecycle Hooks:
interface OnModuleInit {
onModuleInit(): Promise<void> | void
// Called after module and its imports are registered
}
interface OnApplicationBootstrap {
onApplicationBootstrap(): Promise<void> | void
// Called after all modules are initialized
}
interface BeforeApplicationShutdown {
beforeApplicationShutdown(signal?: string): Promise<void> | void
// Called before shutdown begins
}
interface OnModuleDestroy {
onModuleDestroy(): Promise<void> | void
// Called during teardown
}
interface OnApplicationShutdown {
onApplicationShutdown(signal?: string): Promise<void> | void
// Called as final shutdown step
}
Graceful Shutdown:
const app = await createApplication(AppModule)
const hono = app.getInstance()
const server = serve({ fetch: hono.fetch, port: 3000 })
// Handle shutdown signals
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down...')
await app.close('SIGTERM')
server.close()
process.exit(0)
})
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down...')
await app.close('SIGINT')
server.close()
process.exit(0)
})
4. Caching Pattern with Redis
@injectable()
export class CacheService {
constructor(private readonly redis: RedisAccessor) {}
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get().get(key)
return value ? JSON.parse(value) : null
}
async set(key: string, value: any, ttlSeconds: number): Promise<void> {
await this.redis.get().set(key, JSON.stringify(value), 'EX', ttlSeconds)
}
async del(key: string): Promise<void> {
await this.redis.get().del(key)
}
}
5. Error Handling Pattern
// Business exception
export class BizException extends HttpException {
constructor(
public readonly code: number,
message: string,
public readonly data?: any,
) {
super(
{ statusCode: 400, code, message, data },
400,
message,
)
}
}
// Specific business errors
export const ErrorCodes = {
USER_NOT_FOUND: 1001,
INVALID_CREDENTIALS: 1002,
EMAIL_ALREADY_EXISTS: 1003,
} as const
// Usage in service
@injectable()
export class UserService {
async findById(id: string) {
const user = await this.db.query.users.findFirst(...)
if (!user) {
throw new BizException(
ErrorCodes.USER_NOT_FOUND,
`User ${id} not found`,
)
}
return user
}
}
// Exception filter
@injectable()
export class BizExceptionFilter implements ExceptionFilter<BizException> {
async catch(exception: BizException, host: ArgumentsHost) {
const httpContext = host.switchToHttp().getContext()
return new Response(
JSON.stringify({
success: false,
code: exception.code,
message: exception.message,
data: exception.data,
timestamp: new Date().toISOString(),
}),
{
status: exception.getStatus(),
headers: { 'content-type': 'application/json' },
}
)
}
}
6. WebSocket Pattern
The @afilmory/websocket package provides a Redis-backed WebSocket gateway with channel subscriptions, pub/sub fan-out, and automatic heartbeat management.
WebSocket Module Setup:
import { Module } from '@afilmory/framework'
import { RedisModule } from '../redis/redis.module'
import { WebSocketGatewayProvider } from './websocket.provider'
import { WebSocketService } from './websocket.service'
@Module({
imports: [RedisModule],
providers: [WebSocketGatewayProvider, WebSocketService],
})
export class WebSocketModule {}
WebSocket Gateway Provider:
import { injectable } from 'tsyringe'
import { OnModuleInit, OnModuleDestroy, createLogger } from '@afilmory/framework'
import { RedisPubSubBroker, RedisWebSocketGateway } from '@afilmory/websocket'
import { RedisAccessor } from '../redis/redis.provider'
@injectable()
export class WebSocketGatewayProvider implements OnModuleInit, OnModuleDestroy {
private gateway?: RedisWebSocketGateway
private broker?: RedisPubSubBroker
private subscriber?: Redis
constructor(private readonly redis: RedisAccessor) {}
async onModuleInit(): Promise<void> {
const publisher = this.redis.get()
const subscriber = publisher.duplicate()
this.subscriber = subscriber
this.broker = new RedisPubSubBroker({ publisher, subscriber })
}
async attachToHttpServer(server: Server): Promise<void> {
if (!this.broker) {
throw new Error('Broker not initialized')
}
this.gateway = new RedisWebSocketGateway({
broker: this.broker,
server,
path: '/ws',
heartbeatIntervalMs: 30000,
allowClientPublish: false, // Disable client-initiated publish
handshakeValidator: async (request) => {
// Validate auth token from query params or headers
const token = new URL(request.url!, 'http://localhost').searchParams.get('token')
if (!token) {
throw new Error('Missing authentication token')
}
// Validate token here
},
identifyClient: async (request) => {
// Return unique client identifier
return extractUserIdFromRequest(request)
},
})
await this.gateway.start()
}
async onModuleDestroy(): Promise<void> {
await this.gateway?.stop()
await this.subscriber?.quit()
}
getGateway(): RedisWebSocketGateway {
if (!this.gateway) {
throw new Error('Gateway not initialized')
}
return this.gateway
}
}
WebSocket Service:
@injectable()
export class WebSocketService {
constructor(private readonly gatewayProvider: WebSocketGatewayProvider) {}
async publishToChannel<T>(channel: string, payload: T): Promise<void> {
const gateway = this.gatewayProvider.getGateway()
await gateway.publish({ channel, payload })
}
async notifyUser(userId: string, notification: Notification): Promise<void> {
await this.publishToChannel(`user:${userId}`, {
type: 'notification',
data: notification,
})
}
async broadcastToAll(message: string): Promise<void> {
await this.publishToChannel('broadcast', {
type: 'announcement',
message,
})
}
}
Bootstrap with WebSocket:
import { serve } from '@hono/node-server'
import { createApplication } from '@afilmory/framework'
async function bootstrap() {
const app = await createApplication(AppModule)
const hono = app.getInstance()
const server = serve({ fetch: hono.fetch, port: 3000 })
// Attach WebSocket gateway to HTTP server
const container = app.getContainer()
const wsProvider = container.resolve(WebSocketGatewayProvider)
await wsProvider.attachToHttpServer(server)
}
bootstrap()
Client-Side Usage:
// Connect to WebSocket
const ws = new WebSocket('ws://localhost:3000/ws?token=YOUR_TOKEN')
ws.onopen = () => {
// Subscribe to channels
ws.send(
JSON.stringify({
type: 'subscribe',
channels: ['user:123', 'broadcast'],
}),
)
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
switch (message.type) {
case 'ack':
console.log('Subscribed to:', message.channels)
break
case 'message':
console.log('Received:', message.channel, message.payload)
break
case 'error':
console.error('Error:', message.code, message.message)
break
}
}
// Unsubscribe from channels
ws.send(
JSON.stringify({
type: 'unsubscribe',
channels: ['broadcast'],
}),
)
// Ping-pong for keepalive
ws.send(JSON.stringify({ type: 'ping' }))
Key Points:
- Redis Pub/Sub: Uses Redis for message distribution across multiple server instances
- Channel Subscriptions: Clients subscribe to channels and receive real-time updates
- Automatic Heartbeat: Built-in ping/pong mechanism for connection health
- Handshake Validation: Validate authentication before accepting connections
- Client Identification: Custom logic to identify connected clients
- Server-Side Publish: Services can publish messages through the gateway
7. Task Queue Pattern
The @afilmory/task-queue package provides a robust task queue system with support for retries, priority, delayed execution, and middleware.
Task Queue Module Setup:
import { Module } from '@afilmory/framework'
import { TaskQueueModule } from '@afilmory/task-queue'
import { TaskQueueManager } from './task-queue.manager'
import { TaskQueueService } from './task-queue.service'
import { TaskQueueController } from './task-queue.controller'
@Module({
imports: [TaskQueueModule],
controllers: [TaskQueueController],
providers: [TaskQueueManager, TaskQueueService],
})
export class QueueModule {}
Task Queue Worker with Decorators:
import { injectable } from 'tsyringe'
import { OnModuleDestroy, OnModuleInit, createLogger } from '@afilmory/framework'
import { RedisQueueDriver, TaskContext, TaskProcessor, TaskQueue, TaskQueueManager } from '@afilmory/task-queue'
import { RedisAccessor } from '../redis/redis.provider'
@injectable()
export class TaskQueueWorker implements OnModuleInit, OnModuleDestroy {
private readonly logger = createLogger('Tasks:worker')
public queue!: TaskQueue
constructor(
private readonly manager: TaskQueueManager,
private readonly redis: RedisAccessor,
) {}
async onModuleInit(): Promise<void> {
const driver = new RedisQueueDriver({
redis: this.redis.get(),
queueName: 'core:jobs',
visibilityTimeoutMs: 45_000,
})
this.queue = this.manager.createQueue('core-jobs', {
driver,
start: false,
logger: this.logger,
middlewares: [
async (context, next) => {
this.logger.debug('Task started', { taskId: context.taskId, name: context.name })
const start = Date.now()
try {
await next()
} finally {
this.logger.debug('Task finished', {
taskId: context.taskId,
name: context.name,
duration: Date.now() - start,
})
}
},
],
})
await this.queue.start({ pollIntervalMs: 200 })
}
@TaskProcessor('send-email', {
options: {
maxAttempts: 3,
backoffStrategy: (attempt) => Math.min(60_000, 2 ** attempt * 1_000),
retryableFilter: (error) => error instanceof NetworkError,
},
})
async sendEmail(payload: EmailPayload, context: TaskContext<EmailPayload>): Promise<void> {
context.logger.info('Sending email', { to: payload.to })
await sendEmail(payload)
}
@TaskProcessor('process-image', {
options: (instance) => ({
maxAttempts: 5,
backoffStrategy: (attempt) => attempt * 5_000,
}),
})
async processImage(payload: ImagePayload, context: TaskContext<ImagePayload>): Promise<ImageResult> {
context.logger.info('Processing image', { imageId: payload.imageId })
return await processImage(payload)
}
@TaskProcessor({ name: 'deliver-webhook', queueProperty: 'queue' })
async deliverWebhook(payload: WebhookPayload, context: TaskContext<WebhookPayload>): Promise<void> {
try {
await fetch(payload.url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload.data),
})
} catch (error: any) {
if (error?.status === 429) {
context.setRetry({ retry: true, delayMs: 60_000 })
}
throw error
}
}
async onModuleDestroy(): Promise<void> {
await this.queue?.shutdown()
}
}
Handlers are bound automatically after onModuleInit finishes, so as long as the queue property is set before the method resolves, the decorator wires everything up. Options may be an object or a factory that receives the instance (helpful for per-environment tuning).
Task Queue Service:
@injectable()
export class TaskQueueService {
constructor(private readonly worker: TaskQueueWorker) {}
async enqueueEmail(email: EmailPayload): Promise<string> {
const task = await this.worker.queue.enqueue({
name: 'send-email',
payload: email,
priority: 5,
})
return task.id
}
async enqueueDelayedTask(payload: unknown, delayMs: number): Promise<string> {
const task = await this.worker.queue.enqueue({
name: 'deliver-webhook',
payload,
runAt: Date.now() + delayMs,
priority: 0,
})
return task.id
}
async enqueueBatch(emails: EmailPayload[]): Promise<string[]> {
return await Promise.all(emails.map((email) => this.enqueueEmail(email)))
}
async getQueueStats() {
return await this.worker.queue.getStats()
}
}
Task Queue Controller:
@Controller('queue')
export class TaskQueueController {
constructor(private readonly queueService: TaskQueueService) {}
@Post('/tasks/email')
async enqueueEmail(@Body() dto: EmailDto) {
const taskId = await this.queueService.enqueueEmail({
to: dto.to,
subject: dto.subject,
body: dto.body,
})
return {
taskId,
status: 'queued',
message: 'Email task enqueued successfully',
}
}
@Post('/tasks/delayed')
async enqueueDelayed(@Body() dto: { payload: unknown; delaySeconds: number }) {
const taskId = await this.queueService.enqueueDelayedTask(dto.payload, dto.delaySeconds * 1000)
return {
taskId,
status: 'scheduled',
scheduledFor: new Date(Date.now() + dto.delaySeconds * 1000),
}
}
@Get('/stats')
async getStats() {
return await this.queueService.getQueueStats()
}
}
Advanced Task Handler with Custom Retry:
@TaskProcessor('complex-task', {
options: {
maxAttempts: 5,
backoffStrategy: (attempt) => {
const base = 2 ** attempt * 1_000
const jitter = Math.random() * 1_000
return Math.min(300_000, base + jitter)
},
},
})
async complexTaskHandler(payload: ComplexPayload, context: TaskContext<ComplexPayload>): Promise<void> {
try {
await performComplexOperation(payload)
} catch (error) {
if (error instanceof TemporaryError) {
context.setRetry({ retry: true, delayMs: 30_000 })
throw error
}
if (error instanceof PermanentError) {
throw new TaskDropError('Permanent failure, cannot retry')
}
throw error
}
}
Task Middleware:
// Logging middleware
const loggingMiddleware: TaskMiddleware = async (context, next) => {
console.log(`[${context.name}] Starting task ${context.taskId}`)
const start = Date.now()
try {
await next()
console.log(`[${context.name}] Completed in ${Date.now() - start}ms`)
} catch (error) {
console.error(`[${context.name}] Failed:`, error)
throw error
}
}
// Metrics middleware
const metricsMiddleware: TaskMiddleware = async (context, next) => {
const labels = { taskName: context.name }
tasksTotal.inc(labels)
const timer = tasksLatency.startTimer(labels)
try {
await next()
tasksSuccess.inc(labels)
} catch (error) {
tasksFailure.inc(labels)
throw error
} finally {
timer()
}
}
// Apply middlewares
const queue = new TaskQueue({
name: 'main',
middlewares: [loggingMiddleware, metricsMiddleware],
})
Key Points:
- Task Processors: Annotate methods with
@TaskProcessor(); registration runs automatically afteronModuleInit - Priority Queue: Tasks with higher priority are processed first
- Delayed Execution: Schedule tasks to run at a specific time
- Retry Strategies: Exponential backoff, linear backoff, custom logic via handler options or
context.setRetry - Middleware: Add cross-cutting concerns like logging, metrics, tracing
- Driver Support: In-memory driver for development, Redis driver for production
- Visibility Timeout: Prevents tasks from being processed by multiple workers
- Graceful Shutdown: Stop processing and wait for in-flight tasks to complete
8. OpenAPI & Scalar Docs
Generate synchronized documentation from the existing decorator metadata and surface it through a hosted Scalar UI.
- Two-dimensional tagging: Operations carry module-path and controller tags (e.g.
Root / User,User) so clients can cluster endpoints by feature. - Schema reuse: DTO Zod definitions become reusable components referenced by parameters and request bodies.
- Interactive docs: The Scalar embed mirrors their recommended CDN usage, so no bundler work is required.
- Customization: Use
@ApiTags('Admin')on controllers or handlers to add business-facing groupings, and@ApiDoc({ summary, deprecated, tags })to tweak individual operations inline.
Testing Strategy
Framework Testing
The framework itself has 100% test coverage. When implementing features:
1. Unit Tests for Services:
import { describe, it, expect, beforeEach } from 'vitest'
import { container } from 'tsyringe'
describe('UserService', () => {
let service: UserService
let mockDb: DbAccessor
beforeEach(() => {
// Setup mocks
mockDb = {
get: () => mockDbInstance,
} as any
container.register(DbAccessor, { useValue: mockDb })
service = container.resolve(UserService)
})
it('should find user by id', async () => {
const user = await service.findById('123')
expect(user).toBeDefined()
})
})
2. Integration Tests for Controllers:
import { describe, it, expect } from 'vitest'
import { createApplication } from '@afilmory/framework'
describe('UserController', () => {
let app: HonoHttpApplication
beforeEach(async () => {
app = await createApplication(UserModule)
})
it('should return user list', async () => {
const hono = app.getInstance()
const res = await hono.request('/users')
expect(res.status).toBe(200)
const data = await res.json()
expect(Array.isArray(data)).toBe(true)
})
afterEach(async () => {
await app.close()
})
})
3. E2E Tests:
describe('Authentication Flow', () => {
it('should login and access protected route', async () => {
// Login
const loginRes = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'test@example.com', password: 'pass' }),
})
const { access_token } = await loginRes.json()
// Access protected route
const profileRes = await fetch('http://localhost:3000/api/auth/profile', {
headers: { authorization: `Bearer ${access_token}` },
})
expect(profileRes.status).toBe(200)
})
})
Best Practices for AI Agents
When Creating New Features
-
Start with the Module:
- Create module file with
@Module()decorator - Define imports, controllers, providers
- Create module file with
-
Create DTOs with Zod:
- Define schemas with
z.object() - Create DTO classes with
extend createZodSchemaDto()
- Define schemas with
-
Implement Service:
- Add
@injectable()decorator - Use constructor injection for dependencies
- Implement business logic methods
- Add
-
Implement Controller:
- Add
@Controller(prefix)decorator - Use HTTP method decorators
- Use parameter decorators for input
- Inject service via constructor
- Add
-
Add Enhancers if Needed:
- Guards for authorization
- Pipes for custom validation
- Interceptors for cross-cutting concerns
- Filters for error handling
-
Register in Root Module:
- Add to
importsarray in root module
- Add to
Common Pitfalls to Avoid
❌ Don't:
- Import types instead of classes for DI
- Forget
@injectable()decorator on services - Forget
@Controller()decorator on controllers - Use relative imports for cross-module dependencies
- Mutate request/response objects directly
✅ Do:
- Import actual classes for DI
- Use decorators consistently
- Use
HttpContextfor request-scoped data - Follow module boundaries
- Return plain objects (framework handles Response creation)
- Use lifecycle hooks for initialization/cleanup
Code Organization
src/
├── modules/
│ ├── user/
│ │ ├── user.module.ts
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── dto/
│ │ │ ├── create-user.dto.ts
│ │ │ └── update-user.dto.ts
│ │ └── entities/
│ │ └── user.entity.ts
│ └── auth/
│ ├── auth.module.ts
│ ├── auth.controller.ts
│ ├── auth.service.ts
│ └── guards/
│ └── auth.guard.ts
├── guards/ # Shared guards
├── interceptors/ # Shared interceptors
├── pipes/ # Shared pipes
├── filters/ # Shared filters
├── database/ # Database module
│ ├── database.module.ts
│ └── database.provider.ts
├── redis/ # Redis module
│ ├── redis.module.ts
│ └── redis.provider.ts
├── app.module.ts # Root module
└── index.ts # Bootstrap
Quick Reference
Essential Imports
// Framework core
import {
Module,
Controller,
Get,
Post,
Put,
Patch,
Delete,
Body,
Query,
Param,
Headers,
Req,
ContextParam,
UseGuards,
UsePipes,
UseInterceptors,
UseFilters,
HttpContext,
HttpException,
BadRequestException,
UnauthorizedException,
ForbiddenException,
NotFoundException,
createApplication,
createZodValidationPipe,
createZodSchemaDto,
} from '@afilmory/framework'
// DI
import { injectable } from 'tsyringe'
// Validation
import { z } from 'zod'
// Hono types
import type { Context } from 'hono'
Minimal Working Example
// app.module.ts
import { Module } from '@afilmory/framework'
import { AppController } from './app.controller'
import { AppService } from './app.service'
@Module({
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
// app.service.ts
import { injectable } from 'tsyringe'
@injectable()
export class AppService {
getMessage() {
return { message: 'Hello World!' }
}
}
// app.controller.ts
import { Controller, Get } from '@afilmory/framework'
import { AppService } from './app.service'
@Controller('app')
export class AppController {
constructor(private readonly service: AppService) {}
@Get('/')
async getMessage() {
return this.service.getMessage()
}
}
// index.ts
import 'reflect-metadata'
import { serve } from '@hono/node-server'
import { createApplication } from '@afilmory/framework'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await createApplication(AppModule)
const hono = app.getInstance()
serve({ fetch: hono.fetch, port: 3000 })
}
bootstrap()
This framework provides a robust foundation for building enterprise-grade HTTP services with TypeScript. Follow the patterns outlined here, and you'll create maintainable, testable, and scalable applications.