Skip to content

Resolvers and handlers

In flux. This page predates the current endpoint/httpKoa/app.wire model, 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.

LayerLives inKnows aboutReusable across transports?
HandlerdefineAction({ handler }) — the action IS the handlerDomain only — input, events, actors, queriesYes — the same handler runs from HTTP, queue, MCP, CLI, GraphQL
Resolverhttp().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.

ts
// 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):

ts
// 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) and send (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:

ts
// 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:

  1. 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.

  2. 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.

  3. 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 queue subscription({}) are the right names for those jobs. Calling both of them "resolver" hides the difference; keeping them distinct makes the boundary legible.

  4. 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 is defineAction.

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 tools

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

ts
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

MIT licensed.