Postboy Help

Middleware

Middleware is a typed interception layer that runs around every fire, fireCallback, and exec call on the bus. Use it for cross-cutting concerns — logging, tracing, metrics, validation, and access control — so that handlers and executors stay focused on business behavior. Middleware can observe every operation and cancel it before it runs.

The contract

Middleware extends the abstract PostboyMiddleware class and overrides its hooks.

import { PostboyMiddleware, PipelineContext, MiddlewareDecision, MiddlewareDecisionType, } from '@artstesh/postboy'; class LoggingMiddleware extends PostboyMiddleware { canHandle(context: PipelineContext): boolean { return true; // default — participate in every operation } before(context: PipelineContext): MiddlewareDecision { console.log('attempt', context.stage, context.message.id); return { type: MiddlewareDecisionType.Continue }; } after(context: PipelineContext, result?: unknown): void { console.log('done', context.stage, context.message.id, result); } }

Member

Purpose

Default

name

Identifies the middleware in cancellation details

class name; override via super('MyName')

canHandle(context)

Filter: false skips before and after for that operation

true

before(context)

Pre-hook; the only place that can cancel the operation

Continue

after(context, result?)

Post-hook; result is the executor return value on the Execute stage

no-op

dispose()

Cleanup; called on removal and on bus disposal

no-op

PipelineContext carries two fields: stage (which pipeline phase is running) and message (the fired message or executed executor). Treat it as read-only.

The three stages

Every bus operation maps to exactly one MiddlewareStage:

Stage

Runs around

after receives

MiddlewareStage.Publish

postboy.fire(message)

no result

MiddlewareStage.Callback

postboy.fireCallback(message)

no result; the hook fires on every emitted result value

MiddlewareStage.Execute

postboy.exec(executor)

the executor's return value

Two nuances:

  • fireCallback without an action dispatches lazily: before runs at call time, delivery happens when the returned Observable is subscribed.

  • Infrastructure messages are executors, so they pass through the Execute stage like any application command.

What runs when

For every operation the bus walks the chain in registration order:

  1. canHandle(context) — middleware returning false is skipped entirely.

  2. before(context) — the first Interrupt decision cancels the operation immediately.

  3. The operation runs: subscribers are notified, or the executor is invoked.

  4. after(context, result?) — for each middleware whose canHandle matched.

dispose() is lifecycle-level, not per-operation. See Cancellation for what an interrupt does.

Adding and removing middleware

Middleware is managed with infrastructure messages, like every other bus mutation:

import { PostboyService, AddMiddleware, RemoveMiddleware } from '@artstesh/postboy'; const postboy = new PostboyService(); const logging = new LoggingMiddleware(); postboy.exec(new AddMiddleware(logging)); // append to the chain postboy.exec(new RemoveMiddleware(logging)); // remove by identity; calls logging.dispose()
  • The postboy.addMiddleware(...) and postboy.removeMiddleware(...) service methods were removed in 3.5. The messages are the only way to mutate the chain.

  • The chain keeps insertion order. Adding the same instance twice runs its hooks twice.

  • postboy.dispose() calls dispose() on every registered middleware.

Filtering with canHandle

context.message.id is the static ID of the message class — the same key the bus routes by. It is stable per message type, which makes it the primary filter alongside stage.

import { MiddlewareStage } from '@artstesh/postboy'; class AuditMiddleware extends PostboyMiddleware { canHandle(context: PipelineContext): boolean { return ( context.stage === MiddlewareStage.Execute && context.message.id === CreateUserExecutor.ID ); } // before/after now run only for that executor }

Infrastructure messages travel through the Execute stage with their own IDs. Prefix your application IDs, or exclude the known infrastructure IDs, when middleware must not touch them:

canHandle(context: PipelineContext): boolean { return context.stage === MiddlewareStage.Execute && context.message.id.startsWith('app.'); }

Pitfalls

  • canHandle runs for every middleware on every operation. Keep it cheap and side-effect free.

  • after is not guaranteed. It is skipped when any middleware interrupts. Put mandatory cleanup in dispose(), not in after.

  • A locked message type is silently not delivered, but its middleware hooks still run as if the operation succeeded — middleware cannot tell a locked dispatch from a delivered one.

Next steps

07 September 2026