A NestJSβinspired, Honoβpowered enterprise template for building modular, typeβsafe HTTP services. The core framework package ships with dependency injection, decorators, guards, pipes, interceptors, exception filters, requestβscoped context, and an extensible pretty logger. The framework tests achieve 100% coverage and the sample app demonstrates all enhancement paths endβtoβend.
- Hono application layer: Hono performance with opinionated structure and decorators.
- Modular architecture + DI:
tsyringe-based container, constructor injection, module imports/exports. - Request context:
HttpContextbuilt onAsyncLocalStorageto safely access the currentContextanywhere. - Composables (enhancers): Guards, Pipes, Interceptors, and Exception Filters with a declarative API.
- Zod validation pipe: metadata-driven DTO validation via
createZodValidationPipe({ ... })and@ZodSchemadecorators. - Pretty logger: Namespaced, colorized output with CI-safe text labels and hierarchical
extend(). - Task queue decorators: Register background job handlers with
@TaskProcessor()and let the queue wire itself up. - OpenAPI explorer: Generate OpenAPI 3.1 docs from decorators and serve them through Scalar.
- First-class testing: Framework Vitest suite with 100% coverage; demo app covers all enhancer paths.
- Infrastructure providers via DI: Postgres (Drizzle) and Redis (ioredis) wired as modules.
| Path | Description |
|---|---|
apps/core |
Demo application showcasing modules, controllers, and all enhancers; usable as a starter |
packages/framework |
Core framework: HonoHttpApplication, decorators, HTTP context, logger, Zod pipe, etc. |
packages/framework/tests |
Vitest suite for the framework with coverage and lifecycle tests |
packages/db |
Drizzle schema & types plus migrations configuration |
packages/env |
Runtime env validation powered by @t3-oss/env-core |
packages/redis |
Redis client factory (ioredis) and strong types |
packages/websocket |
Redis-backed WebSocket gateway with pub/sub broker, heartbeat management, and logging |
- Node.js 18+ (uses
AsyncLocalStorageand modern ESM tooling) - pnpm 10+
- TypeScript 5.9
# install dependencies
pnpm install
# run framework tests (with coverage)
pnpm -C packages/framework test
# run demo app tests
pnpm -C apps/core test
# start the demo app (vite-node)
pnpm -C apps/core dev
# or run the in-process demo runner
pnpm -C apps/web devCoverage reports are generated at packages/framework/coverage.
Create a .env file at the repo root with at least:
DATABASE_URL=postgres://user:pass@localhost:5432/db
REDIS_URL=redis://localhost:6379
# Optional WebSocket gateway configuration
# WEBSOCKET_ENABLED=true
# WEBSOCKET_PORT=8081
# WEBSOCKET_PATH=/ws
# WEBSOCKET_HEARTBEAT_INTERVAL_MS=30000
# Optional Postgres pool tuning
PG_POOL_MAX=10
PG_IDLE_TIMEOUT=30000
PG_CONN_TIMEOUT=5000import { Controller, Get, Query, UseGuards, Module } from '@hono-template/framework'
@Controller('demo')
export class DemoController {
constructor(private readonly service: DemoService) {}
@Get('/hello')
@UseGuards(ApiKeyGuard)
async greet(@Query('name') name: string) {
return this.service.greet(name)
}
}
@Module({
controllers: [DemoController],
providers: [DemoService, ApiKeyGuard],
})
export class DemoModule {}Bootstrapping with createApplication(RootModule, options) performs:
- Recursive module registration via
imports. - DI registration of
providersandcontrollersusingtsyringe. - Route discovery from class/method decorators and mapping to Hono.
- Per-request pipeline: Guards β Pipes (global/method/parameter) β Interceptors β Controller β Filters.
Providers and controllers may implement lifecycle interfaces inspired by NestJS:
OnModuleInitβonModuleInit()after a module and its imports finish registering.OnApplicationBootstrapβonApplicationBootstrap()after the app finishes initialization.BeforeApplicationShutdownβbeforeApplicationShutdown(signal?)prior to shutdown.OnModuleDestroyβonModuleDestroy()during teardown.OnApplicationShutdownβonApplicationShutdown(signal?)as the final shutdown step.
Call await app.close('SIGTERM') on the HonoHttpApplication instance to trigger shutdown hooks.
@UseGuards(...guards):CanActivate.canActivate(ctx)returningboolean | Promise<boolean>.falsethrowsForbiddenException.@UsePipes(...pipes)and parameter-level pipes (e.g.,@Param('id', ParseIntPipe)): merged globally and per-method.@UseInterceptors(...interceptors):interceptor.intercept(context, next)chaining.@UseFilters(...filters): handle and customize error responses; unhandled errors return a 500 JSON payload.
Zod validation is provided by registering DTO classes with createZodSchemaDto(...) (or the lower-level @ZodSchema(...)) and enabling a global createZodValidationPipe({ ... }). See packages/framework/tests/application.spec.ts for full examples.
Both the database and Redis are registered as DI-driven modules in the demo app.
- Database lives under
apps/core/src/databaseand exposes aDbAccessorthat returns a request-aware Drizzle instance. - Redis lives under
apps/core/src/redisand exposes aRedisAccessorthat returns a singletonioredisclient.
Ensure DatabaseModule and RedisModule are imported by your root module (already wired in the demo):
import { Module } from '@hono-template/framework'
import { DatabaseModule } from '../database/module'
import { RedisModule } from '../redis/module'
import { AppModule } from './app/app.module'
@Module({
imports: [DatabaseModule, RedisModule, AppModule],
})
export class AppModules {}Using Redis from a service via DI:
import { injectable } from 'tsyringe'
import { RedisAccessor } from '../redis/providers'
@injectable()
export class CacheService {
constructor(private readonly redis: RedisAccessor) {}
async setGreeting(key: string, name: string): Promise<void> {
await this.redis.get().set(key, name, 'EX', 60)
}
async getGreeting(key: string): Promise<string | null> {
return await this.redis.get().get(key)
}
}The @hono-template/websocket package provides a Redis-backed WebSocket gateway with channel subscriptions, Redis pub/sub fan-out, and automatic heartbeat/ping management. The demo app exposes it through WebSocketDemoModule (disabled by default). The /api/websocket/info route reports status, and /api/websocket/channels/:channel/publish publishes payloads to connected clients.
The @hono-template/task-queue package ships with a decorator-driven registration model so workers only need to annotate their handler methods:
import { injectable } from 'tsyringe'
import { OnModuleDestroy, OnModuleInit } from '@hono-template/framework'
import { RedisQueueDriver, TaskContext, TaskProcessor, TaskQueue, TaskQueueManager } from '@hono-template/task-queue'
@injectable()
export class NotificationQueue implements OnModuleInit, OnModuleDestroy {
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:notifications',
visibilityTimeoutMs: 45_000,
})
this.queue = this.manager.createQueue('notifications', {
driver,
start: false,
middlewares: [
async (ctx, next) => {
ctx.logger.debug('start', { taskId: ctx.taskId })
await next()
},
],
})
await this.queue.start({ pollIntervalMs: 250 })
}
@TaskProcessor('send-notification', {
options: {
maxAttempts: 5,
retryableFilter: () => true,
backoffStrategy: (attempt) => Math.min(30_000, 2 ** attempt * 250),
},
})
async sendNotification(payload: NotificationPayload, context: TaskContext<NotificationPayload>): Promise<void> {
// business logic here
context.logger.info('Delivered notification', { taskId: context.taskId })
}
async onModuleDestroy(): Promise<void> {
await this.queue?.shutdown()
}
}@TaskProcessor() delays registration until onModuleInit finishes so that the queue instance is ready, supports alternate queue property names, and accepts per-handler options (or an options factory). Any service can inject the queue to enqueue work:
@injectable()
export class NotificationService {
constructor(private readonly worker: NotificationQueue) {}
async enqueue(payload: NotificationPayload) {
return await this.worker.queue.enqueue({ name: 'send-notification', payload })
}
}The framework can build an OpenAPI 3.1 document directly from module and controller decorators and expose it alongside a Scalar-powered UI.
import type { Hono } from 'hono'
import { ApiDoc, ApiTags, createOpenApiDocument } from '@hono-template/framework'
import { AppModules } from './modules/index.module'
function registerDocs(app: Hono, prefix = '/api') {
const document = createOpenApiDocument(AppModules, {
title: 'Core Service API',
version: '1.0.0',
description: 'Decorator-generated OpenAPI spec',
globalPrefix: prefix,
servers: [{ url: prefix }],
})
const specPath = `${prefix}/openapi.json`
const docsPath = `${prefix}/docs`
app.get(specPath, (ctx) => ctx.json(document))
app.get(docsPath, (ctx) => ctx.html(renderScalarHtml(specPath)))
}createOpenApiDocument() groups operations by module and controller, providing consistent tags for consumers, while the Scalar embed above mirrors the recommended CDN integration.
Decorate controllers or individual handlers with @ApiTags() to introduce domain-specific groupings, and use @ApiDoc({ summary, tags, deprecated, ... }) to fine-tune operation metadata without leaving your code.
Handlers may return Response, string, ArrayBuffer, ArrayBufferView, ReadableStream, or plain objects. Non-Response values are normalized to a proper HTTP response. undefined or returning context.res preserves the current response.
import { createLogger } from '@hono-template/framework'
const logger = createLogger('App')
logger.info('Service started')
logger.warn('Auth failed', { userId })
const scoped = logger.extend('Module')
scoped.debug('Loaded')Logger options include custom writer, color strategy, clock, per-level colors, and CI-safe text labels. The framework uses namespaces Framework, Framework:DI, and Framework:Router internally.
HttpContext.run(context, fn) establishes a request scope backed by AsyncLocalStorage. The store is a typed object that always includes the active Hono Context as store.hono and can be extended via module augmentation. Use HttpContext.get()/HttpContext.getValue('hono') inside guards, interceptors, or services, and HttpContext.assign()/setValue() to attach custom request metadata.
- Framework tests:
pnpm -C packages/framework test(coverage threshold 100%). - Demo app tests:
pnpm -C apps/core test. - Type checking: use TypeScript 5.9; optionally run
pnpm tsc --noEmitat the repo root.
import 'reflect-metadata'
import { serve } from '@hono/node-server'
import { createApplication, createZodValidationPipe } from '@hono-template/framework'
import { AppModule } from './app.module'
const ValidationPipe = createZodValidationPipe({
transform: true,
whitelist: true,
errorHttpStatusCode: 422,
forbidUnknownValues: true,
})
const app = await createApplication(AppModule, { globalPrefix: '/api' })
app.useGlobalPipes(ValidationPipe)
app.useGlobalFilters(AllExceptionsFilter)
app.useGlobalInterceptors(LoggingInterceptor)
const hono = app.getInstance()
serve({ fetch: hono.fetch, port: 3000 })Use tsyringe decorators for providers and constructor injection. When running through transpilers that strip design metadata (e.g. esbuild), add a Reflect.metadata shim so runtime DI still sees parameter types:
import 'reflect-metadata'
import { injectable } from 'tsyringe'
import { Controller, Get } from '@hono-template/framework'
@injectable()
class AppService {
getHello(echo?: string | null) {
return {
message: 'Hello',
timestamp: new Date().toISOString(),
echo: echo ?? undefined,
}
}
}
@Controller('app')
@injectable()
@Reflect.metadata('design:paramtypes', [AppService])
class AppController {
constructor(private readonly service: AppService) {}
@Get('/')
getRoot() {
return this.service.getHello()
}
}@Body, @Query, @Param, @Headers, @Req, @ContextParam extract values and optionally run per-parameter pipes.
Throw HttpException or built-ins like BadRequestException, ForbiddenException, NotFoundException. Custom filters may translate errors into consistent API responses.
import { z } from 'zod'
import { Body, Controller, Post, createZodSchemaDto } from '@hono-template/framework'
const CreateMessageSchema = z.object({
message: z.string().min(1),
tags: z.array(z.string()).default([]),
})
class CreateMessageDto extends createZodSchemaDto(CreateMessageSchema) {}
@Controller('messages')
class MessagesController {
@Post('/:id')
create(@Body() body: CreateMessageDto) {
return { status: 'queued', ...body }
}
}Alternative: call createZodDto(CreateMessageSchema) to obtain a ready-to-use class without extending.
In apps/core/package.json:
dev: start the demo server with vite-node.demo: run an in-process demo exercising routes and enhancers.test: run tests for the demo app.
- NestJS β decorator-driven, layered application architecture.
- Hono β small, fast web framework.
- tsyringe β lightweight dependency injection container.
- Zod β type-safe schema validation.
Customize the framework under packages/framework/src and use apps/core as a reference implementation for modules, controllers, and enhancers. Consider extending with enterprise capabilities (configuration, CQRS, event bus, etc.) as your project evolves.