Skip to content

Asena

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.

Design for humans

Declare the component. Ask for the dependency.

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.

  • IoC container — components are discovered by scanning, never registered by hand.
  • Field injection@Inject sits on the field; constructors stay yours.
  • Pluggable adapters — Ergenecore or Hono behind the same business code.
  • Type-safe validation — Zod schemas at route, controller or global level.
How injection works →
typescript
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() });
  }
}
typescript
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() });
  }
}
Benchmarks
2.49×

Faster than NestJS, with nothing tuned

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 →
Plaintext2.49× faster
202,066 Asena
80,988 NestJS
Request validation3.76× faster
134,793 Asena
35,873 NestJS
Database · read by id2.80× faster
78,023 Asena
27,883 NestJS
Full API endpoint2.24× faster
119,095 Asena
53,236 NestJS

Requests per second, higher is better. Numbers for NestJS on Fastify, NestJS on Bun and every other scenario are published alongside the harness.

OpenAPI

A spec you never write

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.

asena-openapiOpenAPI 3.1Swagger UI
typescript
@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 {}
OpenTelemetry

Every request, already traced

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.

asena-otelOTLPAuto-trace
GET /api/usersSERVER18.4ms
UserController.listINTERNAL15.5ms
UserService.getAllINTERNAL12.1ms
typescript
@Otel({
  serviceName: 'my-app',
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces',
  }),
  autoTrace: { services: true, controllers: true },
})
export class AppOtel extends OtelTracingPostProcessor {}
Microservices

Same decorators, different transport

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.

Redis StreamsKafkaHeadless mode
Read the messaging guide →
typescript
@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);
  }
}
PostProcessor

The extension point the framework uses on itself

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 →
typescript
@PostProcessor()
export class TimingPostProcessor
  implements ComponentPostProcessor
{

  postProcess<T>(instance: T, Class: any): T {
    return withTimers(instance, Class.name);
  }

}
Testing

Boot the web layer, mock the rest

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.

createWebTestcreateTestAppmockComponent
typescript
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();
});

Start in one command

The CLI scaffolds the project, the adapter, the logger and the lint setup. You write the first controller.

Released under the MIT License.