The IoC web framework for Bun
Spring-style component discovery and field injection, in TypeScript — running at native Bun speed. Controllers, services, validation, OpenAPI, tracing and microservices all come out of one container.
No modules to wire, no providers array to keep in sync, no factory boilerplate. Decorate a class and Asena's container finds it at boot, builds it once, and hands it to whoever declared it.
@Inject sits on the field; constructors stay yours.import { Controller, Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { Get } from '@asenajs/asena/decorators/http';
import type { Context } from '@asenajs/ergenecore';
@Service()
export class UserService {
findAll() {
return [{ id: 1, name: 'Ada' }];
}
}
@Controller('/users')
export class UserController {
@Inject(UserService)
private userService: UserService;
@Get('/')
async list(context: Context) {
return context.send({ users: this.userService.findAll() });
}
}import { Controller, Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { Get } from '@asenajs/asena/decorators/http';
import type { Context } from '@asenajs/hono-adapter';
@Service()
export class UserService {
findAll() {
return [{ id: 1, name: 'Ada' }];
}
}
@Controller('/users')
export class UserController {
@Inject(UserService)
private userService: UserService;
@Get('/')
async list(context: Context) {
return context.send({ users: this.userService.findAll() });
}
}Byte-for-byte identical workloads on the same machine, measured with wrk. Asena on Ergenecore over Bun against NestJS on Express over Node.
Full methodology →Requests per second, higher is better. Numbers for NestJS on Fastify, NestJS on Bun and every other scenario are published alongside the harness.
Extend one class and Asena walks the container at boot — every controller, route, Zod schema and status code lands in an OpenAPI 3.1 document. Swagger UI is served from the same config.
@OpenApi({
info: { title: 'My API', version: '1.0.0' },
path: '/api/openapi',
ui: true, // Swagger UI at /api/openapi/ui
})
export class AppOpenApi extends OpenApiPostProcessor {}One decorated class boots the SDK. From there each request produces a full waterfall — server span, controller span, service span — with W3C context propagated in and out, plus request counters and duration histograms per route.
@Otel({
serviceName: 'my-app',
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
}),
autoTrace: { services: true, controllers: true },
})
export class AppOtel extends OtelTracingPostProcessor {}Swap HTTP for a broker without changing how you write code. Request/response and fire-and-forget events sit on the same controller, with retry, DLQ, graceful drain and trace propagation handled by the transport.
Read the messaging guide →@MessageController('order') // prefixes every handler below
export class OrderHandler {
@Inject(OrderService)
private orderService: OrderService;
@MessagePattern('create') // handles 'order.create'
async create(data: CreateOrderDto) {
return this.orderService.create(data);
}
@EventPattern('created') // handles 'order.created'
async onCreated(event: OrderEvent) {
await this.orderService.index(event);
}
}Spring's BeanPostProcessor, in TypeScript. Every component passes through your hook on its way out of the container, so cross-cutting concerns land in one place instead of every class. OpenAPI and OpenTelemetry are built on this exact API — nothing is reserved for the framework.
See what you can hook →@PostProcessor()
export class TimingPostProcessor
implements ComponentPostProcessor
{
postProcess<T>(instance: T, Class: any): T {
return withTimers(instance, Class.name);
}
}The equivalent of Spring's @WebMvcTest. Controllers, middlewares and validators run for real; every other dependency is auto-mocked into a stub shaped like the real class. No database, no Redis, no HTTP client — just the routing and validation you meant to test.
import { createWebTest, silentLogger } from '@asenajs/asena/test';
import { createErgenecoreAdapter } from '@asenajs/ergenecore';
test('returns a user', async () => {
const adapter = createErgenecoreAdapter({ logger: silentLogger });
const { app, mocks } = await createWebTest({
adapter,
controllers: [UserController],
});
mocks.UserService.findById.mockResolvedValue({ id: '1' });
await app
.get('/users/1')
.expectStatus(200)
.expectJson({ id: '1' });
await app.stop();
});The CLI scaffolds the project, the adapter, the logger and the lint setup. You write the first controller.