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
| Tense | Mode | Order | Return value | Errors |
|---|---|---|---|---|
| "X has happened" | parallel | concurrent | ignored | logged, not propagated |
| "X happens in sequence" | series | first-registered first | ignored | logged, do not stop the chain |
| "Before X" | series-bail | first-registered first | ignored | first throw stops the chain |
| "X transforms into Y" | chain | first-registered first | passed forward | first 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.
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.
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.
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.
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
- @nwire/hooks alone
- Framework events — which events use which mode