Resolvers and handlers
In flux. This page predates the current endpoint/
httpKoa/app.wiremodel, and the inbound-adapter surface is being reshaped. The handler-vs-transport split it describes still holds, but the specific wiring shapes (http().wire(),_raw) are not the current surface. Revisit once inbound adapters land as sources.
Two layers people sometimes confuse. They live next to each other, they both call themselves "the thing that runs when a request comes in," and in small examples they collapse into one file. They are not the same layer and they are not interchangeable.
| Layer | Lives in | Knows about | Reusable across transports? |
|---|---|---|---|
| Handler | defineAction({ handler }) — the action IS the handler | Domain only — input, events, actors, queries | Yes — the same handler runs from HTTP, queue, MCP, CLI, GraphQL |
| Resolver | http().wire(route, fn) | The transport it was wired through (HTTP request, route params, response shape) | No — it is the wire-adapter for one transport |
If your handler accepts _raw.state.user, sets a 202, or returns response.created(...) — that's not a handler. That's a resolver wearing the wrong name.
The handler is the transport-agnostic primitive
The handler is what runs inside runtime.execute(action, input, envelope). It receives (input, ctx). ctx exposes request / query / send / use / externalCall / logger. It returns events (or void, or a small record).
The handler does not know whether input came off an HTTP body, a BullMQ job, a CLI flag, or an MCP tool call. The runtime fed it input, and the handler does its work.
// modules/stations/actions/record-wash.ts
export const recordWash = defineAction({
name: "stations.record-wash",
input: z.object({
stationId: z.string(),
car: z.string(),
status: z.enum(["ok", "failed", "manual-review"]),
accountId: z.string().optional(),
}),
emits: [WashRecorded],
handler: async (ctx) => {
const { input } = ctx;
const station = await ctx.actor(Station, input.stationId);
return station.record(input); // method returns the event
},
});This action runs unchanged when:
- An HTTP wire dispatches it (
runtime.execute(recordWash, body, envelope)) - A queue worker dispatches it (
runtime.execute(subscription.action, msg.input, msg.envelope)) - The CLI runner dispatches it (
runtime.execute(action, parsedArgs, envelope)) - A workflow
sends it (ctx.send(recordWash, ...)) - An MCP tool dispatches it
- Another handler
requests it (ctx.execute(recordWash, ...))
That is what "transport-agnostic" means. The handler is already that.
The resolver is the wire-adapter for one transport
A resolver translates from a transport's native shape into the action's input shape, and back. It is by definition transport-specific — that is its job. For HTTP, the resolver lives in http().wire(route, handler):
// apps/main/resolvers/record-wash.ts
export const recordWashRoute = post("/stations/:stationId/washes", {
params: ParamsSchema,
body: BodySchema,
middleware: stationGuard,
openapi: { ... },
});
export const recordWashHandler: HttpHandler<RecordWashInput> = async ({ input, resolve, _raw }) => {
const execute = resolve<ExecuteFn>("execute");
const send = resolve<SendFn>("send");
const user = _raw.state.user as AuthedUser | undefined;
if (input.washType === "premium") {
await send(recordWash, { ...input, accountId: user?.id });
return washQueued(); // 202
}
const result = await execute(recordWash, { ...input, accountId: user?.id });
return washRecorded({ id: result.washId, ... }); // 201
};Things this resolver does that a handler must never do:
- Reads
_raw.state.user— that is the Koa request, scraped by the auth middleware. Bound to HTTP. - Picks between
execute(sync, 201) andsend(async, 202) based on business policy expressed as HTTP semantics. - Returns a tagged response object (
washRecorded/washQueued) whose status code is part of the contract.
A queue worker calling the same action does none of that. It just dispatches:
// queue-worker subscribes "stations.record-wash" to the recordWash action
createQueueWorker(app, transport, {
subscribe: [
subscription({ queue: "stations.record-wash", action: recordWash })
.from("stripe-webhook"),
],
});No middleware. No status code. No _raw. The message input flows straight into the action pipeline (runtime.execute), the handler runs, events publish.
Why we don't extract defineResolver(action, { middleware, handler })
We tried it as a defineResolver primitive and removed it once the layer below it (the per-transport wire) proved to be the right surface.
The reasoning, kept here for the next person who proposes it:
Per-transport concerns don't compose well in one shape. HTTP wants verb + path + middleware + status + openapi metadata. Queues want queue name + retry + DLQ + visibility timeout. CLI wants sub-command + flag parsing + help text. A common shape ends up as a union the framework can't enforce, plus a discriminator the user reads to decide which transport bound them.
The "shared" part is already shared. The handler — the part that runs business logic — is the action's own
handler(the action IS the handler). It is the same function regardless of transport. There is nothing left to extract.The translation IS the transport. Once you accept that request→input is HTTP's job and message→input is the queue's job,
http().wire()and queuesubscription({})are the right names for those jobs. Calling both of them "resolver" hides the difference; keeping them distinct makes the boundary legible.Three call sites stayed transport-agnostic the whole time. the action pipeline (
runtime.execute),ctx.execute,ctx.send— these are how every piece of code that isn't a transport invokes an action. The handler is the unit of reuse. It is already extracted. It isdefineAction.
The pattern: action per operation, adapter per transport
examples/multi-transport/ shows this directly. One file declares the actions (actions.ts), three files wire them to three surfaces:
app/
actions.ts # createTask, completeTask, listTasks — handlers
api.ts # HTTP wire — http().wire(post("/tasks"), ...)
queue-worker.ts # queue wire — pulls jobs, dispatches
mcp-tools.ts # MCP wire — exposes actions as toolsEach transport file is small. The actions don't know they have three surfaces. Add a fourth (GraphQL, gRPC, WebSocket) by writing another 30-line wire — the handlers are untouched.
When the resolver is one line
If a route has no per-transport logic — no status-code switching, no auth-state reading, no response shaping beyond JSON — the resolver collapses to a one-liner:
api.wire(post("/tasks", { body: TaskSchema }), ({ input }) =>
runtime.execute(createTask, input),
);That is the degenerate case people read and think "the resolver IS the handler, so why are they separate?" Because the moment you need 202 vs 201, or _raw.state.user, or per-route middleware, or a tagged response model with OpenAPI metadata — you need the resolver layer back, and you'd rather have one shape that scales than two that don't.
See also
- Action — the transport-agnostic primitive
- Topology — how apps compose and deploy
examples/multi-transport/— one action set, three transportsexamples/station-management/apps/main/resolvers/— full HTTP resolver layer