First steps

Edit docs/guide/02-first-steps.md on GitHub

You 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

SH
bunx @dunx/create-app my-api

It 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 watch

The target directory is the only positional argument. Four flags remain, and none of them chooses anything about the app:

FlagEffect
--name <name>Package name for the app. Defaults to the directory's base
--forceWrite into a directory that already has files in it
--yes, -ySkip the questions and take the minimal template
--helpPrint 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:

FeatureWhat arrivesPulls in
notesCRUD routes with zod validation. The smallest real feature
openapiOpenAPI 3.1 from the routes' own schemas, plus the explorer page
httpCORS, a middleware of your own on the response, and error mapping
guardsRoute guards with @Roles and @Public, and a protected controller
databasedrizzle over bun:sqlite, with a schema, seeds and migrations
usersA repository, a service and validated routes over the databasedatabase
authbetter-auth mounted, with SessionGuard and an audit traildatabase
cacheBun.RedisClient behind a session store, and a two-tier Cache in front of it
websocketsA @Gateway with @OnMessage, PubSub and a Redis relaycache, http
imagesBun.Image resizing and format conversion behind a route
filesUploads and downloads on Bun.file, with a workspace root
jobsbullmq queues over Bun.RedisClient, background handlers forkedimages
messagingRabbitMQ with @AmqpHandler, a topic exchange, traces that cross the broker
healthHealthModule's liveness and readiness probes, wired to the app's indicatorscache, database, files, messaging
throttleA fixed-window rate limit, counted in Redis, with per-route overridescache
schedule@Cron, @Interval and @OnceOnBoot on Bun.cron
assetsA static directory on Bun.file, with a short max-age and an immutable rule
statsRequest, query, Redis command and queue timings, as JSONdatabase, cache, jobs
clientThe 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.ts

bunfig.toml

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

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:

SH
bun add @dunx/auth@$(bun pm pkg get dependencies.@dunx/core --workspaces=false | tr -d '"^')

Why lockstep is a correctness requirement: Packaging.

tsconfig.json

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

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

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

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

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

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

SH
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/greetings
SH
curl localhost:3000/greetings/ada
JSON
{ "greeting": "hello, ada", "served": 1 }
SH
bun test

Add a route

Give the existing route a validated parameter. Add zod:

SH
bun add zod

Then declare the schema and annotate the handler with it:

TS
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:

TS
// 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 });
  }
}
TS
// 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}` };
  }
}
TS
// 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.