Skip to content

Hook contract matrix

@nwire/hooks exposes four dispatch modes. The mode is chosen by the dispatcher, not the listener — so the same handler can be a guard in one chain and a side-effect in another.

This page is the expanded form of §07 of the architecture sketch.

The matrix

TenseModeOrderReturn valueErrors
"X has happened"parallelconcurrentignoredlogged, not propagated
"X happens in sequence"seriesfirst-registered firstignoredlogged, do not stop the chain
"Before X"series-bailfirst-registered firstignoredfirst throw stops the chain
"X transforms into Y"chainfirst-registered firstpassed forwardfirst throw stops the chain

When to use each

parallel — past-tense events

The dispatcher says: "this happened, anyone who cares can react." No ordering guarantee, no return value coordination.

ts
bus.on("OrderWasPaid", sendReceiptEmail)
bus.on("OrderWasPaid", updateLoyaltyPoints)
bus.on("OrderWasPaid", trackAnalytics)
await bus.dispatch("OrderWasPaid", payload, { mode: "parallel" })

series — ordered side effects

The dispatcher says: "these happen in order, but no one rejects." Audit-log-then-metric-then-notify shape.

ts
bus.on("UserSignedUp", auditLog)
bus.on("UserSignedUp", emitMetric)
bus.on("UserSignedUp", notifySlack)
await bus.dispatch("UserSignedUp", payload, { mode: "series" })

series-bail — guard / validation

The dispatcher says: "any of you can reject this." Used for AppBooting, ResolverBeforeDispatch, and ActionAboutToDispatch-style guards.

ts
bus.on("ResolverBeforeDispatch", async (ctx) => {
  if (!ctx.user) throw new UnauthorizedError()
})
bus.on("ResolverBeforeDispatch", async (ctx) => {
  if (await rateLimit.exceeded(ctx)) throw new TooManyRequestsError()
})
await bus.dispatch("ResolverBeforeDispatch", ctx, { mode: "series-bail" })

chain — middleware / transform

The dispatcher says: "each of you transforms the payload; the final value is what comes out." koa-compose-shaped.

ts
pipeline.on("Request", addTraceId)
pipeline.on("Request", normalizeHeaders)
pipeline.on("Request", attachUser)
const final = await pipeline.dispatch("Request", req, { mode: "chain" })

Why one primitive for both

Two primitives (an emitter + a middleware composer) is what every framework ships. They have separate registration APIs, separate testing surfaces, and separate mental models. @nwire/hooks puts them behind one bus.on / bus.dispatch so a single listener can serve both roles depending on how it's dispatched. The cost is one extra argument ({ mode }); the benefit is one mental model.

See also

MIT licensed.