Postboy Help

Executor Patterns

How to run executors safely: error propagation, registration lifecycle, and the practices that keep executor contracts small and testable. The PostboyExecutor<T> contract and the exec verb are covered in Executors.

Error handling

Exceptions propagate synchronously

Postboy does not catch handler exceptions. A throw inside the handler surfaces at the exec call site, exactly like a direct function call:

export class ValidateDiscountExecutor extends PostboyExecutor<boolean> { static readonly ID = 'shop.discount.validate'; constructor(public readonly code: string) { super(); } } postboy.exec(new ConnectExecutor(ValidateDiscountExecutor, (e) => { if (!e.code) throw new Error('Discount code is required'); return discountService.isValid(e.code); })); try { const valid = postboy.exec(new ValidateDiscountExecutor('')); } catch (e) { console.error((e as Error).message); // 'Discount code is required' }

Unregistered executors throw

Calling exec on an ID that was never registered fails with a TypeError — the bus calls an undefined handler. Wrap calls in try/catch where registration is not guaranteed, or guarantee registration at startup.

Expected failures: return a result

When failure is part of the domain, return an error-shaped result instead of throwing:

type RefreshResult = { success: true } | { success: false; reason: string }; export class RefreshCacheExecutor extends PostboyExecutor<RefreshResult> { static readonly ID = 'app.cache.refresh'; constructor(public readonly scope: string) { super(); } } const result = postboy.exec(new RefreshCacheExecutor('users')); if (!result.success) console.warn(result.reason);

Rule of thumb: throw when the caller cannot proceed; return a result when the caller must branch on the outcome.

Middleware cancellation

Middleware can interrupt an exec; the call then throws a CancelError (identify it with error.name === 'PostboyCancelError'). Catch it where cancellation is expected — see Middleware Cancellation.

Lifecycle

Register executors in _up() of a registrator; down() disconnects everything the registrator recorded:

class FeatureRegistrator extends PostboyAbstractRegistrator { protected _up(): void { this.recordExecutor(ValidateDiscountExecutor, (e) => discountService.isValid(e.code)); this.recordExecutor(RefreshCacheExecutor, () => cacheService.refresh('users')); } } const reg = new FeatureRegistrator(postboy, 'feature-a'); reg.up(); // handlers active reg.down(); // handlers disconnected

Without a registrator, remove a handler manually by its message ID:

postboy.exec(new DisconnectMessage(ValidateDiscountExecutor.ID));

See Lifecycle Management for scoping strategies.

Best practices

  • One executor, one operation. Split classes that accumulate unrelated responsibilities; the contract stays obvious and the handler easy to test.

  • Keep the payload minimal. Pass only what the handler needs — no flag arguments that belong elsewhere.

  • Make the result type meaningful. { success: false; reason } beats a bare boolean when callers must react to failure.

  • Do not use executors as events. A notification nobody answers is a PostboyGenericMessage, not an executor — see Events.

  • Test handlers in isolation. A PostboyExecutionHandler is a plain class; call handle(...) directly in unit tests without the bus.

  • Keep domain and infrastructure executors apart. ConnectMessage, ConnectExecutor, and ConnectHandler wire the bus; domain executors carry business operations — see Infrastructure Messages.

Next steps

07 September 2026