Middleware Lifecycle and Contract (before/after/canHandle/dispose)
Summary
Postboy middleware is a small object with a predictable lifecycle. For each bus operation (publish, callback, execute), Postboy calls middleware hooks in a fixed order:
Build a pipeline context (stage + message)
Ask whether the middleware should run (canHandle)
Run pre-hook (before) and optionally interrupt the operation
Run the operation (deliver to subscribers / execute handler)
Run post-hook (after) on successful completion
Eventually, release resources (dispose) when middleware is removed or the bus is disposed
This page defines the behavioral contract you can rely on when implementing middleware.
When to use / When not to use
Use this contract knowledge when you
Write middleware that must be safe (no leaks, no double-cleanup issues).
Need to reason about ordering and cancellation.
Want consistent logging/metrics that aligns with actual execution boundaries.
Do not rely on middleware for
Complex orchestration or workflow logic.
Guaranteed post-hooks on failures (unless explicitly documented elsewhere).
Framework lifecycle semantics (Angular/React/etc.). Keep core middleware framework-agnostic.
The Middleware interface (conceptual)
A Postboy middleware exposes four methods:
canHandle(context)→ boolean
Filter hook. If it returns false, the middleware is skipped for the current operation.before(context)→ decision
Pre-hook. Returns a decision: Continue or Interrupt.after(context, result?)→ void
Post-hook. Called after the operation completes successfully. May receive a result for stages that produce one.dispose()→ void
Cleanup hook. Called when middleware is removed or the middleware service is disposed.
Execution order and guarantees
Per operation: call order
For each operation, Postboy iterates over the middleware list in registration order and applies the same pattern.
For each middleware M:
Build
context = { stage, message }If
M.canHandle(context)is false → skip the middleware for this operationCall
M.before(context)
If it returns Interrupt → the whole operation is cancelled immediately
If the operation completes successfully → call
M.after(context, result?)
What “interrupt” means
If any middleware returns Interrupt from before:
The bus does not proceed to handler execution / message delivery.
Postboy raises a cancellation error.
No later middleware in the chain is executed for that operation.
The
afterhooks are not executed for that operation (because the operation never ran).
This is the key contract: cancellation is decided strictly in the before-phase.
A lifecycle diagram (per operation)
@startuml title Middleware hooks per operation (single message)
participant "Postboy" as Bus participant "Middleware #1" as M1 participant "Middleware #2" as M2 participant "Handlers/Subscribers" as H
Bus -> M1: canHandle(context)? alt false Bus -> M2: canHandle(context)? else true Bus -> M1: before(context) -> decision alt Interrupt Bus --> Bus: cancel operation stop end end
Bus -> M2: canHandle(context)? alt true Bus -> M2: before(context) -> decision alt Interrupt Bus --> Bus: cancel operation stop end end
Bus -> H: run operation (publish/callback/execute) H --> Bus: result? (stage-dependent)
Bus -> M1: after(context, result?) [only if canHandle was true] Bus -> M2: after(context, result?) [only if canHandle was true]
@enduml
Method-by-method contract
1) canHandle(context)
Purpose: fast, explicit filtering.
Expected characteristics:
Should be cheap (called for every middleware for every operation).
Should not have side effects.
Should rely on information available in context:
stage
message (including message type/metadata your app defines)
Common usage patterns:
Stage filter: run only for execute-stage, or only for publish-stage.
Message filter: run only for a subset of message types.
Environment filter: disable in tests or enable only in production.
Pitfall: returning true for everything leads to noisy logs and unnecessary overhead.
2) before(context)
Purpose: pre-execution hook and the only place where you can block the operation.
What it is good for:
Policy checks (authorization/feature flags/maintenance mode).
Correlation/tracing setup (start span, attach correlation id to a context you own).
Timing start (store start timestamp in middleware state keyed by message id).
Rules of thumb:
Keep it deterministic and quick.
If it may throw, document it and treat it as a failure mode (users will see an exception).
Interrupt semantics:
Returning Interrupt cancels immediately.
Do not assume any
afterwill run after an interrupt.
3) after(context, result?)
Purpose: post-execution hook for observation and cleanup of per-operation bookkeeping.
When it is called:
Only if:
canHandlereturned true, andthe operation completed successfully (i.e., it reached the post-hook phase)
Result parameter:
Stage-dependent:
Stages that have a meaningful “result” may provide it here.
For stages without a result, it may be absent/undefined.
Good uses:
Stop timing and record duration.
Log outcome (success) and relevant metadata.
Release per-operation resources stored in middleware state.
Important limitation:
Do not rely on
afterto always run (cancellation or failures may skip it). If you need hard guarantees, design your middleware state management accordingly.
4) dispose()
Purpose: release long-lived resources owned by the middleware instance.
Typical things to clean up:
Subscriptions created by the middleware (if any).
Timers/intervals.
External handles (sockets, file descriptors).
In-memory maps used for timing/correlation (if they can grow unbounded).
When it is called:
When a middleware is explicitly removed from the chain.
When the middleware service is disposed (e.g., bus teardown).
Best practice:
Make
disposeidempotent (safe to call once; avoid throwing).
State management guidance (safe patterns)
Prefer per-message keys for temporary state
If you store temporary state between before and after (like timing), key it by a stable identifier:
message id (or equivalent)
and optionally stage
Always handle the “no after()” case
Because cancellation can skip after:
Use bounded structures (LRU / TTL) for maps if you worry about leaks.
Or avoid storing state unless necessary.
Pitfalls / Troubleshooting
“My after() is not called”
Most common causes:
A middleware earlier in the chain interrupted in
before.The operation failed before reaching the post-hook phase.
canHandlereturned false for that middleware.
How to diagnose:
Add a minimal log in
beforeandafterand include stage + message id.Temporarily narrow the middleware chain to isolate which middleware cancels.
“Middleware runs for everything and slows down the app”
Cause: canHandle always returns true and before does heavy work. Fix:
Add stage filtering.
Add message-type filtering.
Keep
canHandlecheap and push heavy work intoafteronly when needed.
“dispose() is never called”
Cause: middleware is never removed and the bus is never disposed in the app lifecycle. Fix:
Ensure you have a predictable teardown point (app shutdown, test cleanup).
In tests, explicitly dispose the bus or use a harness that does.
Best practices (checklist)
Use
canHandleas your primary “scope” tool (stage + message filters).Keep
beforefast and safe; interrupt only for clear policy reasons.Use
afterfor observation and for clearing per-operation state.Implement
disposefor long-lived resource cleanup; make it safe and idempotent.Document ordering assumptions if your middleware depends on other middleware.
Next steps
[Middleware stages: Publish / Callback / Execute][Interrupt/cancel semantics and error diagnostics][How-to: filter middleware to specific messages][How-to: logging/tracing middleware][Middleware API reference]