Skip to content

The handler

A handler is the unit of work in Nwire: a plain, typed, async function. It takes validated input and a context, does one thing, and returns a result or throws a typed error. That's the whole primitive.

The important part is what a handler doesn't know: it has no idea whether it was called by an HTTP request, a queue message, a cron tick, or an MCP tool. It just takes input and a context. You decide which transports it answers, by wiring it — and wiring the same handler to a second transport is a line, never a rewrite. That's the property the whole framework is built around (see the landing demo).

A handler, with context

ts
import { defineHandler } from "@nwire/handler";
import { z } from "zod";

export const sendInvite = defineHandler("invites.send", {
  input: z.object({ email: z.string() }),
  handler: async (ctx) => {
    const mailer = ctx.resolve<{ send(to: string, body: string): Promise<void> }>("mailer");
    await mailer.send(ctx.input.email, "You're invited.");
    return { invited: ctx.input.email };
  },
});

There are two ways to write one, and the difference is only the shape:

  • A bare function wired directly — async (input, ctx) => …, input first, context second. That's what the hello world does.
  • defineHandler — the named form. Its handler takes a single ctx and reads ctx.input, because the input schema lives on the definition. Reach for it when you want the handler registered and discoverable: dispatched by name, listed in Studio, its ctx fully typed from that schema.

Same primitive either way — the named form just hands you the validated input on the context instead of as the first argument.

The context (ctx) is the handler's window into the running app, and it's the same shape under every transport:

  • input — your data, already validated against the schema. If it didn't match, the handler never ran; the caller got a 400 (or the transport's equivalent) for free.
  • resolve(name) — pull a dependency (a mailer, a database, a storage client) from the container. You code against the contract; the wiring decides the implementation.
  • execute(handler, input) — call another handler and await its result inline, with the current request's correlation carried through. (enqueue(...) is the fire-and-forget sibling, for work that should run later rather than block this response.)
  • emit(Event, payload) — announce that something happened, for listeners to react to.
  • envelope — who and what: tenant, user, correlation, causation.
  • logger — already tagged with the envelope ids, so log lines stitch into the trace.

Wiring it to transports

A handler does nothing until you wire it. The same sendInvite can answer an HTTP route and a queue topic at once:

ts
app.wire(post("/invites", { body: z.object({ email: z.string() }) }), sendInvite);
app.wire(queue("invites.send"), sendInvite);

Add cron(...) or an MCP tool(...) binding later and the handler still doesn't change. The wiring lives at the app's edge; the logic lives in the handler.

Errors and responses

A handler signals failure by throwing a typed defineError — the transport renders it with the right status and a safe message (raw Error messages are redacted by default). To shape what a handler returns to the outside world, a defineResource allow-lists the fields that may leave the building, so an internal record can't leak a passwordHash it happens to carry.

MIT licensed.