First steps
Edit docs/guide/02-first-steps.md on GitHubYou will scaffold an app, walk through each file, then add a route, a service and a test. You need Bun 1.4.3 or newer.
Scaffold
bunx @dunx/create-app my-apiIt asks what the app should do. Enter on an empty selection gives the minimal template:
✓ Features none, the minimal template
Created my-api in my-api/
12 files from the minimal template
Next:
cd my-api
bun install
bun run dev # or `start`, which does not watchThe target directory is the only positional argument. Four flags remain, and none of them chooses anything about the app:
| Flag | Effect |
|---|---|
--name <name> | Package name for the app. Defaults to the directory's base |
--force | Write into a directory that already has files in it |
--yes, -y | Skip the questions and take the minimal template |
--help | Print usage |
Piped, or in CI, it asks nothing and writes the minimal template, so a script
never blocks on a question nothing can answer. To choose features without a
terminal, call scaffold({ target, features }) from @dunx/create-app.
Name the directory . to write into the one you are already in.
The package name is validated against npm's rules before anything is written, so a
directory called My-API produces a second question asking for a name npm takes,
rather than a directory whose bun install fails later. A directory that already
has files in it produces the other question, and answering no writes nothing.
Every @dunx/* version in the generated manifest is resolved at run time from the
version of @dunx/create-app doing the scaffolding. The packages version in
lockstep, so the right version to install is whatever generated the app, and the
template never goes stale between releases.
Choosing features
The minimal template is five files and one route. Pick features from the list to add more:
? Features 3 chosen, 1 pulled in
○ notes CRUD routes with zod validation. The smallest real feature.
◉ openapi OpenAPI 3.1 from the routes own schemas, plus the Swagger UI page.
◉ http CORS, a middleware of your own on the response, and error mapping.
◈ database drizzle over bun:sqlite, with a schema, seeds and migrations.
❯ ◉ users A repository, a service and validated routes over the database.
database comes along as a requirement.
Space toggles. ↑↓ moves. a all, n none. Enter continues.Created my-api in my-api/
33 files, 4 features: openapi, http, database, users
database came along as a requirement◉ means chosen, ◈ means required by another feature you chose, and ○
means not included. Move with ↑ and ↓ (or k and j). a selects
everything and n clears the selection. Ctrl+C quits without writing files.
The set:
| Feature | What arrives | Pulls in |
|---|---|---|
notes | CRUD routes with zod validation. The smallest real feature | |
openapi | OpenAPI 3.1 from the routes' own schemas, plus the explorer page | |
http | CORS, a middleware of your own on the response, and error mapping | |
guards | Route guards with @Roles and @Public, and a protected controller | |
database | drizzle over bun:sqlite, with a schema, seeds and migrations | |
users | A repository, a service and validated routes over the database | database |
auth | better-auth mounted, with SessionGuard and an audit trail | database |
cache | Bun.RedisClient behind a session store, and a two-tier Cache in front of it | |
websockets | A @Gateway with @OnMessage, PubSub and a Redis relay | cache, http |
images | Bun.Image resizing and format conversion behind a route | |
files | Uploads and downloads on Bun.file, with a workspace root | |
jobs | bullmq queues over Bun.RedisClient, background handlers forked | images |
messaging | RabbitMQ with @AmqpHandler, a topic exchange, traces that cross the broker | |
health | HealthModule's liveness and readiness probes, wired to the app's indicators | cache, database, files, messaging |
throttle | A fixed-window rate limit, counted in Redis, with per-route overrides | cache |
schedule | @Cron, @Interval and @OnceOnBoot on Bun.cron | |
assets | A static directory on Bun.file, with a short max-age and an immutable rule | |
stats | Request, query, Redis command and queue timings, as JSON | database, cache, jobs |
client | The outbound half of @dunx/http: retry, backoff and a typed FetchError |
Requirements come along automatically. The list marks them ◈ while you choose.
Import order is construction order, so a database is built before the feature that
reads it and torn down after it.
cache, jobs, throttle and stats want a Redis
or Valkey, websockets wants one only for multi-node fan-out, and messaging wants
RabbitMQ. Each reports itself degraded rather than failing the boot, so the app
still starts without one.
Where the code comes from
Every feature directory is copied out of dunx's own
examples/full.
CI builds, typechecks, tests and tours that service on every push. A byte-for-byte
parity test fails the moment a copy drifts from the example.
The wiring cannot be copied. app.module.ts, config.ts and main.ts name every
feature at once in the full example, so those three are generated for the
selection. The config carries only the variables your features read, and the
manifest only the dependencies they need.
CI scaffolds every feature alone, the whole set, and the combinations with something to get wrong, then typechecks each one.
The *.demo.ts files are the full example's scripted walkthroughs, and they come
along because their module registers them. They are executable documentation of the
feature; delete one and its providers entry when you do not want it.
What you got
my-api/
bunfig.toml
package.json
tsconfig.json
README.md
AGENTS.md
CLAUDE.md
.gitignore
src/
main.ts
app.module.ts
greetings.service.ts
greetings.controller.ts
app.test.tsbunfig.toml
# The one line that makes constructor injection work. The compiler plugin records
# each class's constructor parameter types so the container can resolve them.
# Without it, providers are built with no arguments and boot fails saying so.
preload = ["@dunx/transform/preload"]
[test]
preload = ["@dunx/transform/preload"]The only setup dunx asks for. @dunx/transform/preload registers a Bun plugin
that reads each class's constructor parameter types as the file loads and records
them on the class, so the container can resolve them before calling new.
There are two preload entries because Bun's test runner reads its own. The
top-level one covers bun run start and bun src/main.ts; the [test] one
covers bun test. Miss the second and your app runs but your suite does not.
Without the plugin, boot fails and the error message shows the snippet above. For
using it in a Bun.build production build, see
Providers.
package.json
{
"name": "my-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/main.ts",
"start": "bun src/main.ts",
"test": "bun test",
"typecheck": "bun check"
},
"dependencies": {
"@dunx/core": "^0.1.0",
"@dunx/http": "^0.1.0",
"@dunx/transform": "^0.1.0"
},
"devDependencies": {
"@dunx/testing": "^0.1.0",
"@types/bun": ">=1.4.3"
},
"engines": {
"bun": ">=1.4.3"
}
}"type": "module" is required. Every dunx package is ESM only, and
without it verbatimModuleSyntax raises TS1287 against ESM syntax.
@dunx/transform is a runtime dependency rather than a dev dependency because the
preload runs when you run the app. If you compile ahead of time with
Bun.build({ plugins: [depsPlugin] }), it becomes build-time only and can move.
@dunx/testing is a dev dependency. It is what src/app.test.ts uses.
dev is --watch, which restarts the process on a change. --hot swaps modules
in place, but a container is built once at boot. The old providers would still
hold the socket the new ones are trying to bind.
Keep every @dunx/* on the same version
The scaffold already does this, and it matters when you add a package by hand later.
dunx releases in lockstep, and the packages peer-depend on each other, so a
mismatched @dunx/* version warns on install at best. At worst it installs two
copies of @dunx/core, and a provider bound against one is invisible to a
resolution against the other. When you add a package, match the version to the
ones already installed:
bun add @dunx/auth@$(bun pm pkg get dependencies.@dunx/core --workspaces=false | tr -d '"^')Why lockstep is a correctness requirement: Packaging.
tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["bun"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules"]
}Do not enable experimentalDecorators or emitDecoratorMetadata. dunx uses
TC39 standard decorators, and those flags change decorator semantics under you.
moduleResolution: nodenext is why relative imports carry a .js extension:
import { AppModule } from './app.module.js'. The file on disk is .ts. The
specifier is what the emitted declaration would carry, and an extensionless one
fails to resolve for consumers on node16 or nodenext. Under this setting it
is a compile error.
verbatimModuleSyntax requires type-only imports to use import type { Input }.
Do not use import type for a class you inject: it has no runtime value, so the
container cannot resolve that constructor parameter. See
Providers.
src/main.ts
import { HttpFactory } from '@dunx/http';
import { AppModule } from './app.module.js';
const app = await HttpFactory.create(AppModule);
app.enableShutdownHooks();
const url = await app.listen(3000);
console.log(`listening on ${url}greetings`);
await app.closed;Four lines of real work.
HttpFactory.create(AppModule) builds the container from the root module's import
graph, resolves every provider, awaits every async factory, runs every onInit,
then discovers each controller's routes and rejects any collision. It does not
bind a port. There is no separate init() step: resolution is eager, so an app
that exists is an app that booted.
app.enableShutdownHooks() registers SIGTERM and SIGINT handlers. Pass your
own list to change that. On a signal, the server stops first, then every provider
with an onShutdown method runs in reverse construction order.
app.listen(3000) builds the Bun.serve route table and starts the server.
After this, setGlobalPrefix, use, set and enableCors all throw, because
the routes and middleware are fixed when the server starts.
It returns the URL with a trailing slash, so the log line concatenates
greetings directly.
await app.closed holds the process. The promise resolves once shutdown has
finished, whoever triggered it.
src/app.module.ts
import { Module } from '@dunx/core';
import { GreetingsController } from './greetings.controller.js';
import { GreetingsService } from './greetings.service.js';
@Module({
controllers: [GreetingsController],
providers: [GreetingsService],
})
export class AppModule {}@Module is a marker. It writes its options onto the class under
Symbol.for('dunx.module') and the class is never instantiated. controllers and
providers are registered identically; the split exists so the HTTP adapter knows
which constructed instances to scan for routes.
A bare class in either list binds the class to itself, so you do not write
provide(GreetingsService, { useClass: GreetingsService }). Modules covers imports, exports, and how
ordering works.
src/greetings.service.ts
import { Logger, type OnInit } from '@dunx/core';
export class GreetingsService implements OnInit {
#greeted = 0;
constructor(private readonly logger: Logger) {}
onInit(): void {
this.logger.info('greetings ready');
}
greet(name: string): { greeting: string; served: number } {
this.#greeted++;
return { greeting: `hello, ${name}`, served: this.#greeted };
}
}A plain class. No decorator, no registration boilerplate. Listing it in a module's
providers is what makes it injectable.
Declaring Logger in the constructor is all it takes to inject it. Nothing in
this app binds Logger, and it still resolves: AppFactory.create binds a
default Logger and RequestContext after all your modules. If a module binds
either one, the module's binding is used.
The default is ConsoleLogger, which writes one JSON line per entry and reaches
for no dependency. It performs no sanitizing, masking or rotation, so swapping in
@dunx/infra/logger earns its place later.
Logger is an abstract class rather than an interface. The transform records
constructor parameter types, and an interface has no runtime value to record,
so an interface in that position would be a boot error at the injection site.
implements OnInit is structural. The container checks for an onInit method by
shape, so the implements clause is only there to make TypeScript check the
signature for you. onInit runs after the whole graph is constructed, in
dependency order.
src/greetings.controller.ts
import { Controller, Get, type Input, type RouteSchemas } from '@dunx/http';
import { GreetingsService } from './greetings.service.js';
@Controller('greetings')
export class GreetingsController {
constructor(private readonly greetings: GreetingsService) {}
@Get('/')
index(): { routes: readonly string[] } {
return { routes: ['GET /greetings', 'GET /greetings/:name'] };
}
@Get('/:name')
one({ req }: Input<RouteSchemas>): { greeting: string; served: number } {
return this.greetings.greet(req.params['name'] ?? 'world');
}
}A controller is a provider with routes on it, and GreetingsService in the
constructor is resolved exactly the way the service's own Logger was.
Returning a plain object is enough. @dunx/http wraps it in Response.json().
There is no res to forget to send, and returning a Response yourself passes
through untouched when you need the escape hatch.
@Get('/:name') declares no schemas, so the path parameter stays on
req.params as a string. noUncheckedIndexedAccess is why the ?? 'world'
is there. Declaring a params schema is what makes it typed and coerced, and
Controllers shows that.
Input<RouteSchemas> is the annotation for a route with no options at all; the
parameter has to be annotated, and Validation explains why.
src/app.test.ts
import { describe, expect, test } from 'bun:test';
import { createTestServer } from '@dunx/testing';
import { AppModule } from './app.module.js';
describe('minimal', () => {
test('serves the greeting', async () => {
const server = await createTestServer({ modules: [AppModule] });
const { status, body } = await server.json<{ greeting: string }>(
'greetings/ada',
);
expect(status).toBe(200);
expect(body.greeting).toBe('hello, ada');
await server.close();
});
});createTestServer boots the real application behind a real Bun.serve on port 0.
Nothing is faked. Bun.serve binds in about a millisecond, and a fake would only
be able to prove the parts of the request path dunx wrote rather than the parts
Bun owns: routing, params, method dispatch, upgrades.
modules takes one module or several, so you do not need a separate test
module. Request logging is off in tests unless you turn it on.
server.json(path) returns { status, headers, body } in one await. server.request()
gives you the raw Response for bytes, HTML or a header assertion. server.close()
is app.shutdown(): it stops the server, then tears the container down.
AGENTS.md and CLAUDE.md
AGENTS.md describes this app's layout and commands, and the rules that make dunx
fail at boot: do not add @Injectable(), put .js on relative imports, and do not
use import type for an injected class. An app generated with features also lists
those features and the services they need running.
CLAUDE.md is four lines pointing at it, so both filenames find the same
instructions and there is one file to edit.
Neither restates the framework. They link setup.md and llms.txt, which are served from the documentation site and move with each release.
Run it
cd my-api
bun install
bun run dev # or `bun run start`, which does not watch{"level":"info","message":"greetings ready", ...}
listening on http://localhost:3000/greetingscurl localhost:3000/greetings/ada{ "greeting": "hello, ada", "served": 1 }bun testAdd a route
Give the existing route a validated parameter. Add zod:
bun add zodThen declare the schema and annotate the handler with it:
import { Controller, Get, type Input, type RouteSchemas } from '@dunx/http';
import { z } from 'zod';
import { GreetingsService } from './greetings.service.js';
const oneGreeting = {
params: z.object({ name: z.string().min(1).max(40) }),
} as const satisfies RouteSchemas;
@Controller('greetings')
export class GreetingsController {
constructor(private readonly greetings: GreetingsService) {}
@Get('/:name', oneGreeting)
one({ params }: Input<typeof oneGreeting>): {
greeting: string;
served: number;
} {
return this.greetings.greet(params.name);
}
}params.name is now a string the schema has already checked, and the
?? 'world' is gone because the field cannot be missing. A request that fails the
schema never reaches the handler; it gets a 400 whose body carries every issue.
as const satisfies RouteSchemas is the convention for a hoisted options object.
satisfies checks the shape without replacing the type, so a misspelled field is
caught at the declaration. What you must not write is
const oneGreeting: RouteSchemas = { ... }: that annotation makes params
optional, Input<typeof oneGreeting> degrades to bare { req }, and every
handler loses its typed input.
Add a service
A second provider, injected into the first. No annotation anywhere:
// src/audit.service.ts
import { Logger } from '@dunx/core';
export class AuditService {
readonly seen: string[] = [];
constructor(private readonly logger: Logger) {}
record(name: string): void {
this.seen.push(name);
this.logger.info('greeted', { name, total: this.seen.length });
}
}// src/greetings.service.ts
import { AuditService } from './audit.service.js';
export class GreetingsService {
constructor(private readonly audit: AuditService) {}
greet(name: string): { greeting: string } {
this.audit.record(name);
return { greeting: `hello, ${name}` };
}
}// src/app.module.ts
@Module({
controllers: [GreetingsController],
providers: [GreetingsService, AuditService],
})
export class AppModule {}Order inside providers does not matter. The container resolves dependencies
recursively, so AuditService is built before GreetingsService regardless of
where it appears in the list. What order does control is teardown, and
Modules covers that.
If you forget to list AuditService, this app still works: a class no module lists
is registered in the module that first asks for it. If a second module then
injects it, boot fails, because the first module does not export it. List it in
the module that owns it.
This has two side effects. A typo in a module's providers list is not caught.
And an abstract class that is injected but never bound is constructed as-is
instead of failing.
Providers covers both.
Next
Providers for how injection actually works and every shape of
provide(). Modules for composition, ordering and dynamic
modules. Controllers for routing, validation and errors.
The five example applications in the repository are a ladder:
examples/minimal is this app,
examples/databases sets up SQLite, Postgres and
MySQL, and examples/testing covers overrides and
guards.
examples/full is every package in one long-running
service, and examples/binary compiles an app to a single
executable.