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.

Coding with an AI agent?
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.
  • Route context contracts — one schema validates, types and documents a route.
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() });
  }
}
Route context contracts

Type-safe from schema to handler

Declare what a route accepts once. The same schema validates the request, types the handler and documents the endpoint in OpenAPI.

Request validation

A contract is a context class built with Context.define(). Body, query, params and headers are validated before the handler runs, so only valid data ever reaches it.

Bring Zod or any Standard Schema library — Asena depends on none of them.

import { Context } from '@asenajs/ergenecore'
import { z } from 'zod'

class CreateUserContext extends Context.define({
  body: z.object({
    name: z.string().trim(),
    age: z.coerce.number().default(18),
  }),
  query: z.object({
    notify: z.stringbool().default(false),
  }),
}) {}

Inferred, not annotated

context.input carries each schema's output type — defaults, coercions and transforms included. No generics, no casts, no DTO class kept in sync by hand.

Your getters and helpers live on the contract class and stay typed too.

@Controller('/users')
export class UserController {
  @Post('/')
  async create(context: CreateUserContext) {
    const user = context.input.body(property) body: {
    name: string;
    age: number;
}
    const notify = context.input.query.notify(property) notify: boolean

    return context.send({ user, notify }, 201)
  }
}

Undeclared input does not compile

Only the targets a contract declares exist on context.input. Reading one it never validated is a compile error, not a runtime surprise.

@Get('/:id')
async find(context: CreateUserContext) {
  const { id } = context.input.paramsProperty 'params' does not exist on type
Readonly<InferInputs<{ body: …; query: … }>>
ts(2339)
}
Benchmarks
2.59×

Faster than NestJS, with nothing tuned

Byte-for-byte identical workloads on the same machine, one CPU and 2 GB per server, measured with wrk. Asena on Ergenecore over Bun against NestJS on Express over Node.

Full methodology →Run it yourself →
Plaintext2.59× faster
211,630 Asena
81,623 NestJS
Request validation3.87× faster
135,012 Asena
34,847 NestJS
Database · read by id, ORM1.74× faster
46,422 Asena
26,636 NestJS
Full API endpoint2.59× faster
133,533 Asena
51,650 NestJS

Requests per second, higher is better. Numbers for NestJS on Fastify, NestJS on Bun and every other scenario, plus the harness and raw results, are in AsenaJs/benchmark.

OpenAPI

A spec you never write

Extend one class and Asena walks the container at boot — every controller, route contract, Zod schema and status code lands in an OpenAPI 3.1 document. Scalar or Swagger UI is served from the same config.

asena-openapiOpenAPI 3.1ScalarSwagger UI
typescript
@OpenApi({
  info: { title: 'My API', version: '1.0.0' },
  path: '/api/openapi',
  ui: 'scalar', // or 'swagger' — served 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, contracts 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.