Postboy Help

Middleware Cancellation

A middleware cancels an operation by returning { type: MiddlewareDecisionType.Interrupt } from its before(...) hook. The bus then throws a CancelError from fire, exec, or fireCallback: the operation does not run and after hooks are skipped. Use interruption for policy enforcement — read-only mode, feature flags, access control. Keep business validation that must produce typed responses inside handlers.

Returning Interrupt

import { PostboyMiddleware, PipelineContext, MiddlewareDecision, MiddlewareDecisionType, MiddlewareStage, } from '@artstesh/postboy'; class ReadonlyGuard extends PostboyMiddleware { constructor(private readonly isReadonly: () => boolean) { super('ReadonlyGuard'); // descriptive name — it appears in cancellation details } canHandle(context: PipelineContext): boolean { return context.stage === MiddlewareStage.Execute; } before(_context: PipelineContext): MiddlewareDecision { return this.isReadonly() ? { type: MiddlewareDecisionType.Interrupt } : { type: MiddlewareDecisionType.Continue }; } }

What happens on interrupt

  • The operation does not run: subscribers are not notified (Publish), the request is not delivered (Callback), the executor is not invoked (Execute).

  • Middleware later in the chain is not evaluated for that operation — the first Interrupt wins.

  • after(...) is skipped for every middleware, not only the one that interrupted.

  • fire, exec, and fireCallback throw a CancelError synchronously from the call itself.

The CancelError

class CancelError extends Error { readonly details: CancelDetails; readonly name = 'PostboyCancelError'; }

error.details carries:

Field

Meaning

stage

The stage whose before hook cancelled the operation

middleware

name of the middleware that returned the interrupt

messageId

Static ID of the cancelled message or executor

namespace

Reserved for namespace attribution; not populated by the built-in pipeline

reason

Generated by the pipeline: Cancelled by middleware "<name>"; also used as the error message

MiddlewareDecision carries only type — there is no custom reason field. The reason is derived from the middleware name, so pass a descriptive name to the constructor to make cancellations searchable in logs.

Catching CancelError at call sites

Catch the error where cancellation is an expected outcome, and re-throw everything else:

import { CancelError } from '@artstesh/postboy'; try { postboy.exec(new DeleteUserExecutor(userId)); } catch (e) { if (e instanceof CancelError || (e as Error).name === 'PostboyCancelError') { console.warn('blocked by', e.details.middleware, 'on', e.details.messageId); return; } throw e; // a real failure, not a cancellation }

The error is thrown by the call itself, before anything is returned. For fireCallback, wrap the call — when the request is cancelled, the Observable is never created, so there is nothing to catch in the subscription.

Blocking by stage

Choose the stage deliberately in canHandle:

  • Execute — the natural place for hard guards: intentional commands with a synchronous boundary.

  • Callback — request/response flows: reject disabled features or unauthorized requests before delivery.

  • Publish — avoid. Events are facts that other components rely on; blocking them hides signals and causes inconsistencies. Guard the command or request that causes the event instead.

Scope guards narrowly. A broad canHandle cancels operations you did not intend to block, and every cancellation is visible to callers as a thrown error.

Pitfalls

  • Do not throw from before to block an operation. A thrown error is an unexpected failure; interruption is a decision, and only a decision produces the structured CancelError.

  • after never runs for a cancelled operation. Never allocate resources in before that only after would release.

  • Distinguish cancellation from failure before swallowing errors at call sites — check error.name, not the message text.

Next steps

07 September 2026