Postboy Help

Best Practices

A handful of habits covers almost every postboy pitfall: unique message IDs, one owner per registration, registration before subscription, and explicit teardown. Each rule below links to the topic that covers it in depth.

Keep one registration per message ID

The bus routes by the static ID string, and the store keeps exactly one registration per ID. Re-registering an ID — from another feature, another namespace, or a second up() — overrides the earlier registration with only a console warning. Register each message class once, in one place, and treat a duplicate registration as a bug. See Registration and Caveats.

Group a feature's registrations in one registrator

Put all of a feature's record* calls into a single PostboyAbstractRegistrator subclass so that one down() disconnects everything the feature owns. Scattered Connect* calls are individually correct but collectively untraceable — teardown gets skipped and registrations leak. See Lifecycle Management.

class FeatureRegistrator extends PostboyAbstractRegistrator { protected _up(): void { this.recordSubject(UserLoggedInMessage) .recordBehavior(SessionStateMessage, new SessionStateMessage('anonymous')); } }

Register first, subscribe after

fire on an unregistered ID throws, and sub on an unregistered ID throws as well. Register in up() or module initialization, and subscribe only afterwards — dependent services get this ordering for free via registerServices. See How It Works and Lifecycle Management.

Match the subject type to the semantics

Use recordReplay when late subscribers need the latest value on subscribe, and recordBehavior for state that must always have a current value. Plain recordSubject is for events where missing history is fine. See Lifecycle Management and Events.

this.recordReplay(LastSearchMessage, 1); // latest value on subscribe this.recordBehavior(CartStateMessage, new CartStateMessage([])); // state, never empty

Prefer registrators over bare Connect* in application code

Bare postboy.exec(new ConnectMessage(...)) leaves the ID untracked; you must remember the DisconnectMessage yourself. Reserve it for app-lifetime wiring, prototypes, and tests. Anything with a feature lifetime belongs in a registrator or namespace. See Lifecycle Management and Namespaces.

Put validation in before, side effects in after

Middleware runs for every fire, fireCallback, and exec. Return an Interrupt decision from before to cancel an invalid operation before it runs; use after for logging and metrics, where you can also see the executor's result. See Middleware and Middleware Recipes.

class AuthMiddleware extends PostboyMiddleware { canHandle(context: PipelineContext): boolean { return context.stage === MiddlewareStage.Execute; } before(context: PipelineContext): MiddlewareDecision { return isAllowed(context.message.id) ? { type: MiddlewareDecisionType.Continue } : { type: MiddlewareDecisionType.Interrupt }; } }

Catch CancelError where cancellation is expected

An interrupted operation throws CancelError from fire, fireCallback, and exec. If cancellation is a normal outcome for a call site, catch it there and inspect error.details (stage, middleware, messageId, reason). Distinguish it with error instanceof CancelError or error.name === 'PostboyCancelError'. See Middleware Cancellation.

try { postboy.exec(new DeleteAccountExecutor(userId)); } catch (e) { if (e instanceof CancelError) return; // cancelled on purpose — not an error throw e; }

Guard calls whose registration is not guaranteed

fire throws for an unregistered message ID and exec throws for an unregistered executor ID. Where the wiring is owned elsewhere (a lazy feature, a plugin), wrap the call in try/catch or make the dependency explicit. See Executors and Caveats.

try { postboy.fire(new TelemetryPingMessage(section)); } catch { // telemetry feature not active — fine }

Treat locked messages as a no-op

A type locked via LockMessage is silently skipped by fire and fireCallback: registration stays intact and exec is unaffected. Do not build error handling around locks — treat absence of delivery as an expected state. See Infrastructure Messages.

Tear down deliberately

Subjects are not auto-completed by themselves: call registrator.down(), exec(new EliminateNamespace(name)), or postboy.dispose() when the owner is destroyed, and pair every up() with a down(). dispose() also disposes every registered middleware, and the bus must be re-created afterwards. See Lifecycle Management and Namespaces.

At a glance

  • One registration per message ID; duplicates override.

  • One registrator per feature; down() cleans everything.

  • Register in up() or module init; subscribe after.

  • recordReplay for latest value, recordBehavior for state.

  • Validation in before, side effects in after.

  • Catch CancelError only where cancellation is expected.

  • Guard exec/fire where registration is not guaranteed.

  • Locked means no-op, not error.

  • Always tear down: down(), EliminateNamespace, or dispose().

Next steps

07 September 2026