Your first app
Start by scaffolding — not from an empty file. create-nwire gives you a running service with a real bounded context, batteries, typed config, and a production Dockerfile, so your first move is to read working code, not assemble it.
1. Scaffold + run
pnpm create nwire my-app --template service
cd my-app
pnpm install
pnpm devpnpm dev boots the app under HTTP with watch mode and Studio at http://localhost:3000.
curl -X POST http://localhost:3000/api/todos \
-H "content-type: application/json" -H "x-user-id: dina" \
-d '{"text":"water the plants"}'
# 201 {"id":"…","userId":"dina","text":"water the plants","status":"open",…}
curl http://localhost:3000/api/todos -H "x-user-id: dina"
# {"items":[{…}],"total":1}A handler ran, an event published, a projection updated, and Studio drew it.
2. What you got
my-app/
app/
main.ts the entry — binds a port, serves traffic
app.ts the app as a pure value — structure, no side effects
todos/ the worked bounded context, as use-case folders
create-todo.action.ts one file, one use case
list-todos.query.ts
todo.events.ts the facts this BC publishes
on-todo-completed.workflow.ts
routes/ the HTTP surface, explicit + ordered
wires/http.ts the route table — read it to see every endpoint
config/ the control panel — every knob, typed + documented
plugins/ app-specific glue (the auth bridge)
Dockerfile multi-stage, non-root, with a readiness healthcheckThe todos context is the one worked example — you replace it with yours.
3. The pieces
app.ts is structure. It names the app, lists its plugins, and lets the framework discover its primitives. It reads no environment and binds no port, so a test imports it directly.
// app/app.ts
import { registry } from "virtual:nwire-registry"; // generated from app/
export const app = createApp({
appName: "my-app",
logger: new ConsoleLogger({ service: "my-app" }),
registry, // every define* under app/, grouped by kind
plugins: [
...forgePlugins(), // wires what the registry registered
todoStorePlugin(),
mailPlugin(), storagePlugin(), cachePlugin(), // batteries, config-driven
],
});You write define* files under app/; discovery registers them and forgePlugins() reads the runtime by kind. You never hand-list primitives.
A use case is one file. It's callable from a route, another action, a workflow, or a test — never tied to HTTP:
// app/todos/create-todo.action.ts
export const createTodo = defineAction({
name: "todos.create-todo",
description: "Add a todo to the caller's list.",
input: z.object({ userId: z.string(), text: z.string().min(1).max(500) }),
emits: [TodoCreated],
handler: async ({ input, resolve, emit }) => {
const store = resolve<TodoStore>("todos");
const row = store.add({ userId: input.userId, text: input.text });
await emit(TodoCreated, {
id: row.id, userId: row.userId, text: row.text, addedAt: row.addedAt,
});
return row;
},
});Routes stay explicit. app/wires/http.ts lists them in order — an endpoint table you read, never inferred.
config/ is the control panel. config/env.ts is the one place the environment is read; each config/<concern>.ts shapes it into a documented slice. main.ts supplies it at boot (.mount(app, config)); a handler reads ctx.config. See Configuration.
Batteries default to zero-infra. mailPlugin(), storagePlugin(), and cachePlugin() run in-process and read their config.<key> slice. Point one at a real backend by editing config and wiring its adapter — the use cases don't change.
4. Grow it
- Another use case — add a file under
app/todos/(or a new BC folder); export thedefine*and it's registered. - Real backends — flip a driver in
config/, install the adapter, wire it inapp.ts. - Ship it —
pnpm buildbundlesapp/main.tstodist/my-app/main.js;pnpm startruns it on plain Node. See Going to production.
Under the hood: the minimal shape
You don't need the scaffold to run Nwire. The smallest program is a handler wired to a transport:
import { createApp } from "@nwire/app";
import { endpoint } from "@nwire/endpoint";
import { get } from "@nwire/wires/http";
import { httpKoa } from "@nwire/koa";
import { z } from "zod";
const app = createApp({ appName: "hello" });
app.wire(
get("/hello/:name", { params: z.object({ name: z.string() }) }),
async (input) => ({ message: `hello ${input.name}` }),
);
await endpoint("hello", { port: 3000 }).use(httpKoa()).mount(app).run();That's a typed route with graceful shutdown and health probes already wired. The scaffold is this shape grown up — structure, config, and batteries factored into the folders above.
Where to next
- Project structure — multiple bounded contexts.
- Configuration — the control panel in depth.
- Resources + errors — public response shapes, typed errors via
defineError. - Actions + events — when a handler emits domain events instead of mutating directly.