Bun-native
one version number
zero-dependency core

Dependency injection for Bun.
Everything a service needs, one version.

Controllers, dependency injection, validation, OpenAPI, WebSockets, queues, an ORM, auth, a test harness and an ops dashboard. Released together, tested together, on Bun's own primitives.

bun add @dunx/core @dunx/http @dunx/transform

99.6% of raw Bun.serve on plain text, cold start 46.2 ms - measured on this machine, published with the standard deviation and the losses.

export class UsersRepository {
  constructor(private readonly db: DbConnection) {}
}

export class UsersService {
  constructor(private readonly repo: UsersRepository) {}

  findAll() {
    return this.repo.all();
  }
}

0

dependencies

in @dunx/core

99.6%

of raw Bun.serve

plain text

10

packages

907 public exports

97.9%

line coverage

19,633 of 20,053

TC39

standard decorators

no experimentalDecorators

What is actually different

Decisions rather than a feature list. Where one of these carries a number, the number is measured.

Injection without annotation

@dunx/transform reads constructor parameter types at load time and records them as a thunk, so a dependency declared later in the file - or across a circular import - resolves with no forwardRef. An erased type fails at boot and names the parameter.

A core with zero dependencies

The container, modules, lifecycle, configuration and the Logger and RequestContext contracts, with nothing behind them. @dunx/http logs every request in an app that imported no logging module.

One log line per request

On by default, outermost in the chain, carrying request and response in a single entry - 4xx at warn, 5xx at error, unmatched paths included. Middleware wraps next(), so one class sees both halves. It has its own benchmark row: 59.9% of the baseline with it on against 99.6% with it off.

Bun-native all the way down

No express, no ws, no ioredis, no pg, no sharp, no dotenv. Routing is Bun.serve's own router: path params and per-method dispatch in Zig.

Bun.servebun:sqliteBun.SQLBun.RedisClientBun.ImageBun.S3ClientBun.passwordBun.fileBun.Glob

OpenAPI from the schemas you have

Routes validate against Standard Schema, so zod, Valibot and ArkType all work. @dunx/openapi turns those same schemas into an OpenAPI 3.1 document and a self-contained page that makes every route testable - no CDN, no external request.

Tests against a real server

createTestApp replaces providers in place; createTestServer boots a real Bun.serve on port 0. The routing under test is Bun's own.

Work that leaves the request

RabbitMQ behind @dunx/infra/amqp, bullmq over Bun.RedisClient behind /queue, and an in-process EventBus over EventTarget. A handler is a method with @AmqpHandler and nothing else: no registry, no broker token. The publishing request's trace id is stamped into the message headers and read back by the consumer, so a flow that crossed the broker joins in one log query.

Four shapes of connection

Routes, websocket gateways, @Sse over Bun's own ReadableStream, and Connect with gRPC-Web behind @dunx/http/connect - all on one Bun.serve, through the same middleware, CORS and rate limiting. A gateway broadcasting across replicas takes WsRelayModule over Redis or Postgres LISTEN/NOTIFY.

Failure handling in the core

ResiliencePolicy is timeout, retry, backoff, jitter and fallback with no dependency behind it, usable around anything rather than only an outbound call. Caching is memory, Redis or the two tiered, and request, query, queue, Redis and cache metrics each sit behind their own flag, off until asked for.

10 packages. ESM tree-shaking drops what is not imported, so importing one costs nothing for the rest.

Constructor injection, with nothing annotating it

No @Injectable(). No @Inject() - TC39 standard decorators have no parameter decorators, so it does not exist and never will. No reflect-metadata, no experimentalDecorators, no tsyringe.

Annotation-driven
@Injectable()
export class UsersService {
  constructor(
    @Inject(UsersRepository)
    private readonly repo: UsersRepository,
  ) {}
}
dunx
export class UsersService {
  constructor(private readonly repo: UsersRepository) {}
}

What turns it on - one line, once, per app

bunfig.toml
preload = ["@dunx/transform/preload"]

@dunx/transform reads each class's constructor parameter types at load time and records them on the class as a thunk; the container resolves them before calling new. A thunk rather than a value, which is what makes a dependency declared later in the file - or across a circular import - work with no forwardRef.

A parameter whose type erased - an interface, a primitive, a union, a type-only import - is a boot error naming that parameter, not a silent undefined. That is the wart emitDecoratorMetadata has and this does not.

Show me the code

Every sample here is lifted from the example app rather than written for this page. If it is on this page, it runs in CI.

users.controller.ts

@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get('/', listUsers)
  list(input: Input<typeof listUsers>): Promise<readonly User[]> {
    return this.users.findAll(input.query.limit, input.query.q);
  }

  @Get('/:id', oneUser)
  async one(input: Input<typeof oneUser>): Promise<User> {
    // Already a number: the params schema coerced it before this ran.
    const user = await this.users.find(input.params.id);
    if (user === null) {
      throw new HttpError(HttpStatusCode.NOT_FOUND, `No user ${input.params.id}`);
    }
    return user;
  }

  @Post('/', createUser)
  create(input: Input<typeof createUser>): Promise<User> {
    return this.users.create(input.body.name, input.body.tags);
  }
}

The body arrives validated and typed. No req.json(), no Response.json(), no manual status - 201 is the POST default, and a thrown HttpError is mapped for you.

What happens to a request

Each layer wraps the one inside it, and the pulse below is one request descending them and unwinding back out. The layers that call next() see the response on the way, so a single middleware can log both halves. Two of these layers are the runtime rather than the framework.

Bun.serve

matches the route

native

Path params and per-method dispatch happen in Zig. dunx registers routes with Bun and never walks a JavaScript router, so a method miss is Bun’s 404 rather than ours.

RequestLoggingMiddleware

opens a context scope

Outermost in the chain and on by default. It starts an AsyncLocalStorage scope carrying the request metadata, so anything logged deeper in the call stack is correlated without being handed a request object. 4xx logs at warn, 5xx at error.

Global middleware

runs, outermost first

CORS and anything the app added. Middleware wraps next(), so one middleware sees the request and the response. There is no separate interceptor stage.

Guards

accept, or throw

A guard is middleware that throws. return next() allows it through, throw new HttpError(HttpStatusCode.FORBIDDEN) refuses: there is no CanActivate and no boolean to return. Providers resolve eagerly at boot, so a guard prevents the handler being called, not constructed.

Standard Schema

validates params, query, body

Whatever validator the route’s schema came from: zod, Valibot, ArkType. Coercion happens here, so :id is already a number when the handler reads it. A failure is a 400 that never reaches your code.

Your handler

typed input

Constructor-injected dependencies were resolved at boot, not per request. The parameter type is inferred from the same schema that validated it, so nothing is declared twice. Return an object and it becomes JSON, a string stays text, 201 is the POST default. When the handler is synchronous and reads no body, the whole path stays synchronous and no promise is allocated.

can rewrite the response

writes one line, request and response together

writes the socket

An unmatched path is logged too. Bun.serve answers a miss itself, so listen() installs one fetch fallback that puts the global middleware in front of a 404. Bun still does all the matching, and the fallback runs only once it has matched nothing.

How much faster

Plain text, 133,993 req/s, measured on one machine with the load generator sharing its cores. A relative ranking, not a capacity number.

29.5x

faster than Django (Python)

18.3x

faster than FastAPI (Python)

@dunx/http

133,993

Django (Python)

4,538

FastAPI (Python)

7,321

NestJS (Express)

19,640

Express (Node)

27,561

NestJS (Fastify)

37,178

Hono (Node)

41,201

Fastify (Node)

46,443

Spring Boot (JVM)

52,073

node:http (raw)

53,325

@dunx/http (+ request logging, @arkv/logger)

59,967

net/http (Go)

74,976

Gin (Go)

76,480

ASP.NET Core MVC

88,702

ASP.NET Core minimal APIs

110,649

Hono (Bun)

123,815

Axum (Rust)

126,969

Elysia

133,151

read this

Colour encodes the runtime. dunx runs on Bun and every subject in the headline runs on Node, so each multiple combines a runtime difference with a framework difference. Two comparisons isolate the framework: the DI subjects against their own adapters, and dunx against raw Bun.serve, where it costs 0.4%. Every figure here is regenerated from a real run; the full results carry the standard deviation and the scenarios dunx does not win.

Measured

Wed, 09 Sep 2026 05:01:39 GMT

92.1-99.7% of raw Bun.serve

across 5 scenarios. @dunx/http is a layer over Bun.serve, so the gap to it is the framework's own overhead and nothing else. Against the fastest other framework: 0 ahead, 3 tied, 2 behind - tied meaning inside the ±3 point run-to-run band. It boots in 1.9x the baseline's time, which is the loss.

See the full results

plaintext

99.6%

133,993/s

tied

json

95.6%

123,999/s

tied

params

94.4%

120,942/s

behind

validate

92.1%

82,903/s

tied

io

99.7%

27,646/s

behind

Bar is @dunx/http as a fraction of raw Bun.serve; the tick marks where the fastest other framework landed. Closed-loop latency on one shared machine - read the caveats before quoting any of this.

Where it loses

The benchmark publishes the standard deviation and the scenarios dunx does not win, and so does this page. Treat any figure at or above 100% of Bun.serve as noise: @dunx/http dispatches through it and cannot outrun the API it calls.

Cold start is 1.93x raw Bun.serve (raw)

46.2 ms against 24.0 ms, from the oxc-parser preload and eager dependency resolution. It still beats every Node subject by a wide margin, but it is the number to watch if boot time is what you are optimising.

Request logging is not free

The same app reaches 59.9% of the baseline with logging on against 99.6% with it off - one JSON.stringify and one write per request, inside an AsyncLocalStorage scope. It is on by default; requestLogging: false removes it.

Validation costs more than the validator

Reading the body costs roughly three times as much as validating it - about 3 µs for req.json() against about 1 µs for zod. Switching validators moves the smaller number.

The transform has to be preloaded

A class with constructor parameters and no recorded dependencies fails at boot, naming the missing preload. One line of bunfig.toml stands between a fresh clone and a running app.

The full run, every subject and the machine it was measured on are on the benchmarks page.

It does not reinvent your libraries

Where Bun ships no primitive for a hard problem, dunx integrates the best-in-class library instead of competing with it. All four are peer dependencies - you install them and own the version.

zodvalidation
drizzle-ormORM and migrations
better-authauthentication
bullmqqueues

There is no dunx ORM, no dunx validator, no dunx auth flow and no dunx job queue. Where a library offers a Bun-native driver, that driver is mandatory - drizzle-orm/bun-sqlite and drizzle-orm/bun-sql, never pg or better-sqlite3.

Built with dunx

Real applications, outside this repository.

A provably-fair crash game: bet during the window, watch the rocket climb, cash out before it explodes. Every round is verifiable after the fact.

21 modules, 8 controllers, a WebSocket gateway and 4 job handlers across 165 backend files.

@dunx/core
@dunx/http
@dunx/infra
@dunx/auth
@dunx/openapi
@dunx/testing
@dunx/transform

Examples

5 runnable apps in the repository, in the order worth reading them. Every one of them is booted by CI, which is the only reason to trust that they still work.

Five files and nothing else - a service, a controller, a module, HttpFactory.create and the one preload line. Its whole value is being small enough to read in two minutes.

bun run start
examples/databases
sqlite
postgres
mysql

The same repository code against four configurations: SQLite in async and in synchronous mode, Postgres over drizzle-orm/bun-sql, and MySQL over drizzle-orm/mysql-proxy with Bun.SQL as the transport. Anything without a server running reports that it is skipping.

bun run start
examples/testing
@dunx/testing

createTestApp with providers replaced in place, createTestServer on a real Bun.serve at port 0, RecordingLogger, and a guard exercised through the actual request path rather than called directly.

bun test
examples/full
everything

One long-running service that exercises everything at once - DI, config, logging, routes, websocket gateways, guards, database, redis, queues, files, images, auth and OpenAPI. This is the one to open in a browser.

bun run start
examples/binary
bun build --compile

How you ship one. A small CLI - the same container, config and lifecycle a server uses, with no HTTP surface - compiled by Bun.build into a single executable that runs on a host with nothing installed. The transform runs at build time, since a compiled binary has no load-time plugin.

bun run build

The packages

10, and only @dunx/core plus @dunx/http are needed to serve a request.

@dunx/auth
21 exports

Better Auth for dunx: its handler mounted on Bun.serve, a session guard reading @Public() and @Roles(), the caller in async context, and Bun.password hashing

./drizzle
npm
@dunx/core
158 exports

DI container, modules, lifecycle and the injectable Logger contract for the dunx framework

./otel
npm
@dunx/dashboard
29 exports

An opt-in operations page for a running dunx app: routes, the provider graph, gateways, config and runtime health, with bull-board mounted for the queues

./ui
npm
@dunx/http
251 exports

Bun.serve adapter for the dunx framework: controllers, middleware and WebSocket gateways

./client
./connect
npm
@dunx/infra
276 exports

Database, Redis, queue, AMQP, schedule, storage, image and logging infrastructure for dunx. drizzle over bun:sqlite and Bun.SQL, bullmq over Bun.RedisClient, rabbitmq-client for RabbitMQ, plus Bun.file, Bun.Glob, Bun.S3Client, Bun.Image and @arkv/logger

./amqp
./cache
./db
./email
./email/react
./email/resend
./email/smtp
./files
./images
./logger
./pagination
./pagination/cursor
./queue
./redis
./schedule
npm
@dunx/openapi
99 exports

OpenAPI 3.1 documents for dunx controllers, generated from the schemas the routes already validate, with Swagger UI or Scalar mounted over them

./scalar
./swagger
npm
@dunx/testing
15 exports

Test harness for dunx apps: a container with providers replaced in place, and a real Bun.serve on port 0

@dunx/transform
11 exports

Load-time transform that records constructor dependencies for the dunx container

./build
./preload
npm

Scaffold a new dunx application - bunx @dunx/create-app my-api

@dunx/mcp
33 exports

A Model Context Protocol server for dunx: the written guide and scaffold catalogue with no app, plus your routes, providers and modules once you point it at one

./cli
npm

Three steps to a running app

No CLI to install, no scaffold to generate, no decorator metadata to configure.

1

Install the packages

The core, the HTTP layer and the transform. Everything else is opt-in.

bun add @dunx/core @dunx/http @dunx/transform
2

Turn the transform on

One line, once per app. It is what lets constructors go unannotated.

# bunfig.toml
preload = ["@dunx/transform/preload"]
3

Boot the server

Bun.serve underneath, with request logging already on.

await (await HttpFactory.create(AppModule)).listen(3000);