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.
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.GlobOpenAPI 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.
@Injectable()
export class UsersService {
constructor(
@Inject(UsersRepository)
private readonly repo: UsersRepository,
) {}
}export class UsersService {
constructor(private readonly repo: UsersRepository) {}
}What turns it on - one line, once, per app
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.
@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
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
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.
faster than Django (Python)
faster than FastAPI (Python)
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.
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.
plaintext
99.6%
133,993/s
json
95.6%
123,999/s
params
94.4%
120,942/s
validate
92.1%
82,903/s
io
99.7%
27,646/s
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.
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.
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 startThe 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 startcreateTestApp 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 testOne 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 startHow 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 buildThe packages
10, and only @dunx/core plus @dunx/http are needed to serve a request.
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
DI container, modules, lifecycle and the injectable Logger contract for the dunx framework
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
Bun.serve adapter for the dunx framework: controllers, middleware and WebSocket gateways
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
OpenAPI 3.1 documents for dunx controllers, generated from the schemas the routes already validate, with Swagger UI or Scalar mounted over them
Test harness for dunx apps: a container with providers replaced in place, and a real Bun.serve on port 0
Load-time transform that records constructor dependencies for the dunx container
Three steps to a running app
No CLI to install, no scaffold to generate, no decorator metadata to configure.
Install the packages
The core, the HTTP layer and the transform. Everything else is opt-in.
bun add @dunx/core @dunx/http @dunx/transformTurn the transform on
One line, once per app. It is what lets constructors go unannotated.
# bunfig.toml
preload = ["@dunx/transform/preload"]Boot the server
Bun.serve underneath, with request logging already on.
await (await HttpFactory.create(AppModule)).listen(3000);