Postboy Help

Middleware Recipes

Copy-paste middleware patterns for the most common cross-cutting concerns: logging, metrics, scoping, and resource management. Each recipe relies only on the PostboyMiddleware contract (canHandle/before/after/dispose) and the three stages. Attach any of them with postboy.exec(new AddMiddleware(mw)).

Logging and tracing messages

Record attempts in before and completions in after. Attempts survive cancellations; completions do not, because after is skipped when any middleware interrupts.

import { PostboyMiddleware, PipelineContext, MiddlewareDecision, MiddlewareDecisionType, } from '@artstesh/postboy'; class TracingMiddleware extends PostboyMiddleware { canHandle(context: PipelineContext): boolean { return context.message.id.startsWith('app.'); // skip infrastructure messages } before(context: PipelineContext): MiddlewareDecision { logger.debug('postboy:attempt', { stage: context.stage, id: context.message.id }); return { type: MiddlewareDecisionType.Continue }; } after(context: PipelineContext): void { logger.debug('postboy:success', { stage: context.stage, id: context.message.id }); } }

Log stable fields only: stage and message.id (the static type ID, safe for correlation). Do not log payloads by default — they may contain secrets or PII.

Measuring duration and collecting metrics

Capture a start timestamp in before, record the duration in after. Keying by message.id is safe here because exec is synchronous — operations of one type never interleave. Clear the map in dispose as well, because cancelled operations never reach after.

import { PostboyMiddleware, PipelineContext, MiddlewareDecision, MiddlewareDecisionType, MiddlewareStage } from '@artstesh/postboy'; // `metrics` is your metrics adapter class ExecuteMetricsMiddleware extends PostboyMiddleware { private readonly startedAt = new Map<string, number>(); canHandle(context: PipelineContext): boolean { return context.stage === MiddlewareStage.Execute; } before(context: PipelineContext): MiddlewareDecision { this.startedAt.set(context.message.id, Date.now()); metrics.increment('postboy_attempts_total', { id: context.message.id }); return { type: MiddlewareDecisionType.Continue }; } after(context: PipelineContext): void { const started = this.startedAt.get(context.message.id); if (started !== undefined) { metrics.observe('postboy_duration_ms', Date.now() - started, { id: context.message.id }); this.startedAt.delete(context.message.id); } } dispose(): void { this.startedAt.clear(); } }

Label metrics with the static ID only. It is bounded by the number of message types in the codebase; per-instance values such as correlation ids are not. For the Callback stage, remember that after fires on every emitted result value — decide whether you measure "request accepted" or "request completed" before adding latency there.

Targeting a subset of messages

Scope middleware with canHandle: stage first, then message identity. Both hooks are then guaranteed to run only for the matched operations.

class AuditMiddleware extends PostboyMiddleware { private readonly audited = new Set([CreateUserExecutor.ID, DeleteUserExecutor.ID]); canHandle(context: PipelineContext): boolean { return ( context.stage === MiddlewareStage.Execute && this.audited.has(context.message.id) ); } // before/after only run for the audited executors }

Put all scoping in canHandle, never inside before or after — a middleware that filters internally still pays hook overhead everywhere and skews attempt metrics.

Safely managing resources

Everything a middleware allocates must be releasable in dispose. It runs on RemoveMiddleware and on postboy.dispose(). Make it idempotent — it must not throw when there is nothing left to clean.

import { interval } from 'rxjs'; class HealthMiddleware extends PostboyMiddleware { private readonly heartbeat = interval(30_000).subscribe(() => report('alive')); dispose(): void { this.heartbeat.unsubscribe(); } }

Keep per-operation state out of the design when possible. When you cannot avoid it — like the timing map above — treat "no after " as a normal outcome and clear leftovers in dispose.

Troubleshooting

Symptom

Cause

Fix

Middleware never runs

Added to a different PostboyService instance, or canHandle returns false for that stage or message

Attach on the same instance at startup; temporarily return true from canHandle to verify wiring

Middleware runs for everything, logs are noisy

canHandle always returns true, or filtering sits inside before

Move all scoping into canHandle; filter by stage first, then by message.id

after is never called

An earlier middleware interrupted the operation

Expected on cancellation; record attempts in before instead

Operations cancelled unexpectedly

A guard's canHandle matches more than intended

Inspect error.details (middleware, stage, messageId), then narrow the filter

Cannot tell which middleware cancelled

Middleware relies on the default class name or the check uses message text

Check error.details.middleware; pass explicit names via super('ReadonlyGuard')

Middleware fires for unknown messages

Infrastructure messages (AddMiddleware, ConnectMessage, ...) travel through the Execute stage

Exclude them in canHandle, e.g. by ID prefix

Memory grows over time

Per-operation state is cleared only in after, which cancellations skip

Use bounded state and clear it in dispose as well

Tests affect each other

Middleware attached globally and never removed

Remove it in teardown: postboy.exec(new RemoveMiddleware(mw)), or postboy.dispose()

Anti-patterns

  • God middleware that logs, validates, guards, and transforms in one class — split by concern.

  • Business logic in before: routing, orchestration, and domain rules belong in handlers.

  • Throwing from before to block an operation — return Interrupt so callers get a structured CancelError.

  • Cleanup that depends on after — cancellations skip it.

  • Mutating context.message — middleware observes; it does not rewrite intent.

  • Undocumented ordering dependencies — the chain runs in registration order and the first Interrupt wins.

  • Heavy work in before — serialization, payload logging, I/O — it sits on every operation's hot path.

Next steps

07 September 2026