Message Roles
Everything that travels through a Postboy bus is a message. All messages share one abstract base, PostboyMessage, and specialise into three roles: events (PostboyGenericMessage) for fire-and-forget broadcasts, callbacks (PostboyCallbackMessage<T>) for asynchronous request/response, and executors (PostboyExecutor<T>) for synchronous commands. Choosing the right role for each interaction keeps its intent visible and its contract typed.
The base contract: PostboyMessage
PostboyMessage is abstract and never instantiated directly. It gives every message:
id— a getter returning the class's staticID. The bus routes by this string, not by class identity.metadata— a mutablePostboyMessageMetadataobject (correlationId,causationId,tags, plus any custom keys) used for tracing and enrichment.setMetadata(partial)— merges a partial object into the metadata and returns the same instance:
The static ID rule
Every concrete message class declares its own identity:
Three rules apply:
The field must be
static readonlyon the class itself. A class without one fails the built-incheckIdguard with"<ClassName> should have a static ID field".The value must be unique per class. Routing is keyed by
ID, so two classes sharing a string receive each other's messages.Never inherit a parent's
ID. TypeScript inherits static fields, so a subclass without its ownIDsilently reuses the parent's string and collides with it. Declare a freshIDin every subclass.
The three roles
Role | Base class | Verbs | Result | Intent |
|---|---|---|---|---|
Event |
|
| none | "Something happened" |
Callback |
|
|
| "I need a result back" |
Executor |
|
|
| "Run this now and return the value" |
When to use which
Use an event when zero or more listeners may react and no result is expected: domain events, UI notifications, state broadcasts.
Use a callback when the sender depends on a response that arrives asynchronously: queries, remote calls, long-running operations with progress.
Use an executor when the result must be available immediately and synchronously: mapping, validation, formatting, cache lookups.
Do not use an executor for asynchronous work.
execreturns the value directly, so a Promise would come back unresolved; model async work as a callback message instead.
Naming conventions
Postboy does not enforce names. A CQRS-inspired convention scales well:
Suffix | Base class | Contract |
|---|---|---|
|
| pure notification; no result; any number of subscribers |
|
| read request; always finishes with data |
|
| instruction; callback form when it returns a value, generic form when it does not |
|
| synchronous operation returning immediately |
Message lifecycle
From construction to delivery, every message passes the same stages:
Construction — the class is instantiated with its payload; metadata can be attached with
setMetadata.Dispatch — the sender calls
fire,fireCallback, orexec. Unregistered IDs throw onfireandexec; a locked type is silently not dispatched.Middleware — every dispatch runs the pipeline's
before(...)hook; anInterruptdecision throwsCancelErrorand the message is dropped before delivery.Resolution — the bus resolves the registered subject or handler by
id.Delivery — subscribers receive the instance; a callback responder calls
finish(...); an executor handler returns its value. Theafter(...)hook runs once the result is known.Outcome — side effects (event), a completed
Observable<T>(callback), or the returned value (executor).
Middleware details: Middleware.
Pitfalls
Reusing one
IDstring across two classes — messages cross wires with no warning.Letting a subclass inherit a parent's
IDinstead of declaring its own.Awaiting
postboy.exec(...)— it returnsT, not a Promise.Mutating metadata after dispatch — enrich at construction or in middleware, not once the message is in flight.