Middleware Best Practices
Summary
Middleware is an infrastructure extension point around Postboy’s message processing pipeline. It is powerful because it can observe and block operations centrally. It is also risky because it can introduce hidden behavior and cross-cutting side effects.
This page lists best practices that keep middleware:
predictable,
debuggable,
safe (no leaks),
and easy to evolve.
When to use / When not to use
Use middleware for
Observability: logging, tracing, metrics.
Policy enforcement: guards, feature flags, environment constraints.
Operational safety rails: read-only mode, maintenance mode, global rate limits (if applicable).
Avoid middleware for
Business workflow logic (“what should happen next?”).
Domain validation that must return a typed error/result to the caller.
Hidden data mutation or message rewriting that changes meaning without explicit ownership.
Best practices (the checklist)
1) Keep one concern per middleware
Do
Logging middleware does logging.
Guard middleware does policy checks.
Metrics middleware does metrics.
Why
Easier testing and troubleshooting.
Clear ownership and fewer accidental interactions.
Smell
A middleware that logs, validates, retries, transforms data, and opens connections.
2) Filter aggressively in canHandle (stage-first)
Do
Use
canHandle(context)to scope middleware.Filter by stage first, then by message subset.
Why
canHandleis the intended filter gate.Avoids unnecessary overhead for unrelated operations.
Smell
Stage/type checks scattered across
beforeandafter.
3) Prefer “attempt” logging/metrics in before, “success” in after
Do
before: record attempts (and start timestamps/spans).after: record successful completion (and durations).
Why
afterdoes not run on cancellation.You get consistent signals for attempts vs successes.
Smell
Assuming
afteris always called.
4) Treat cancellation as a first-class outcome
Do
Decide explicitly:
Do you want to observe cancelled attempts?
Where do you count cancellations (middleware vs boundary handling)?
Keep cancellation reasons short and searchable.
Why
Interrupt is an intended control-flow mechanism, not an “exceptional edge case”.
Smell
“Random” cancellations with no clear reason or no metadata.
5) Make ordering explicit when it matters
Middleware runs in registration order. The first Interrupt wins.
Do
Document ordering rules in code comments or module docs if you depend on them.
Put guard middleware early to fail fast.
Decide whether observability middleware should be:
before guards (see cancelled attempts), or
after guards (log only successful operations).
Smell
Swapping middleware registration order changes behavior unexpectedly.
6) Keep before() fast, deterministic, and side-effect minimal
Do
Evaluate policy checks quickly (in-memory checks, flags, roles already present).
Avoid long-running work inside
before.
Why
beforeis on the hot path and runs before every guarded operation.
Smell
beforeperforming network calls, file I/O, or heavy serialization.
7) Use structured, low-cardinality observability data
Do
Log structured fields: stage, message label, message id, outcome, duration.
Use stable message labels (type name/category mapping).
Avoid message id as a metric label (use it in logs only).
Why
High-cardinality metrics are expensive and can break your monitoring system.
Smell
Metrics tagged with request ids, message ids, user ids.
8) Do not log payloads by default
Do
Log only minimal identifiers by default.
Provide an explicit “debug mode” (feature flag) if you must log payloads.
Why
Security (secrets/PII) and performance.
Smell
Logging full message objects “for convenience”.
9) Design resource ownership and cleanup from day one
Do
Implement
disposeif you allocate resources.Make
disposesafe and idempotent.Avoid per-operation allocations that require
afterfor cleanup.
Why
Cancellations skip
after; tests and modules need deterministic cleanup.
Smell
Subscriptions/timers created repeatedly and never released.
10) Prefer small, explicit abstractions over “smart” middleware
Do
Keep middleware transparent: it should be easy to predict what it does.
Prefer explicit “policy middleware” rather than implicit behavior changes.
Why
Middleware already adds an indirection layer; keep it understandable.
Smell
Middleware that silently rewrites behavior based on many hidden conditions.
Decision guidance: what belongs where?
Middleware is the right place for
“Always around” concerns (log, measure, trace).
“Always enforced” policies (guards).
Infrastructure-only behaviors.
Handlers/executors are the right place for
Domain behavior and orchestration.
Domain validation that returns typed errors/results.
Business decisions that should be visible in domain code.
Diagram: recommended layering
@startuml title Recommended responsibility split
participant "App code" as App participant "Postboy bus" as Bus participant "Middleware (infra)" as MW participant "Handlers/Subscribers (domain)" as H
App -> Bus: start operation Bus -> MW: before/canHandle (policies + observability) Bus -> H: domain logic (explicit) Bus -> MW: after (observability) Bus --> App: result / completion
@enduml
Troubleshooting: common symptoms mapped to best practices
Symptom: “It’s hard to understand why something happened”
Likely causes:
too many concerns in one middleware
hidden ordering dependencies
poor cancellation diagnostics
Fix:
split middleware by concern
document ordering
add stable policy ids/reasons
Symptom: “Performance degraded after adding middleware”
Likely causes:
broad
canHandleheavy work in
beforepayload logging
Fix:
stage-first filtering
remove heavy work from hot paths
structured minimal logs
Symptom: “Memory grows over time”
Likely causes:
per-operation maps not bounded
subscriptions/timers not cleaned in
dispose
Fix:
bound state (TTL/LRU)
implement
disposeand ensure teardown is wired
Next steps
[Anti-patterns: what to avoid in middleware][Recipes: copy-safe middleware patterns][How-to: safely manage middleware resources (dispose)][How-to: logging and tracing][How-to: measure duration and collect metrics]