Callback Patterns
Practical flows built on callback messages: asynchronous responders, error translation, cancellation, and composing responses from several queries with standard RxJS operators. The anatomy of PostboyCallbackMessage<T> and the fireCallback verb are covered in Callbacks.
Asynchronous processing
The responder may take as long as it needs; the sender only sees the Observable<T>.
Promise-based responder:
postboy.sub(LoadUserQuery).subscribe(async (msg) => {
const user = await api.loadUser(msg.userId);
msg.finish(user ?? null);
});
Observable-based responder:
postboy.sub(LoadUserQuery).subscribe((msg) => {
http.get<User>(`/api/users/${msg.userId}`).subscribe({
next: (user) => msg.finish(user),
error: () => msg.finish(null),
});
});
Error handling
Postboy is not an error boundary: an exception thrown in a responder never reaches the sender. Report failures through the result contract — a nullable value, a fallback such as msg.finish([]), or a discriminated result when the sender must tell failures apart. Never throw from a responder and expect the bus to relay it:
type SettingsResult =
| { status: 'success'; data: Settings }
| { status: 'error'; code: string };
export class LoadSettingsQuery extends PostboyCallbackMessage<SettingsResult> {
static readonly ID = 'app.settings.load';
}
// wrong: the throw is lost inside the subscription; the sender still waits
postboy.sub(LoadUserQuery).subscribe((msg) => {
throw new Error('boom');
});
Cancellation
Unsubscribing the sender's Observable before completion stops the wait and tears the stream down:
const subscription = postboy.fireCallback(new SearchUsersQuery(term, cancel$))
.subscribe((users) => display(users));
To stop the responder's work as well, carry a cancel signal in the message payload — an application-level convention, not a built-in field — and honor it with takeUntil:
export class SearchUsersQuery extends PostboyCallbackMessage<User[]> {
static readonly ID = 'search.users';
constructor(public readonly term: string, public readonly cancel$: Observable<void>) {
super();
}
}
postboy.sub(SearchUsersQuery).subscribe((msg) => {
http.get<User[]>(`/api/users?q=${msg.term}`)
.pipe(takeUntil(msg.cancel$))
.subscribe((users) => msg.finish(users));
});
cancel$.next(); // tears the responder's request down
subscription.unsubscribe(); // and the sender's wait
Composing responses
Every fireCallback returns a typed Observable<T>, so queries compose with ordinary operators:
export class GetCustomerQuery extends PostboyCallbackMessage<Customer> {
static readonly ID = 'crm.customer.get';
constructor(public readonly id: string) { super(); }
}
export class GetOrdersQuery extends PostboyCallbackMessage<Order[]> {
static readonly ID = 'crm.orders.get';
constructor(public readonly customerId: string) { super(); }
}
export class GetUserProfileQuery extends PostboyCallbackMessage<Profile> {
static readonly ID = 'app.user.profile';
constructor(public readonly id: string) { super(); }
}
export class GetUserSettingsQuery extends PostboyCallbackMessage<Settings> {
static readonly ID = 'app.user.settings';
constructor(public readonly id: string) { super(); }
}
Sequential — the second query needs the first result:
postboy.fireCallback(new GetCustomerQuery(customerId)).pipe(
mergeMap((c) => c ? postboy.fireCallback(new GetOrdersQuery(c.id)) : of([] as Order[])),
).subscribe((orders) => render(orders));
Parallel — independent queries gathered with forkJoin:
forkJoin({
profile: postboy.fireCallback(new GetUserProfileQuery(userId)),
settings: postboy.fireCallback(new GetUserSettingsQuery(userId)),
}).subscribe(({profile, settings}) => buildDashboard(profile, settings));
Pitfalls
A responder that never finishes hangs the whole composition — forkJoin waits forever.
Expecting the sender's stream to error; it only receives what the responder passes to next and finish.
Unsubscribing and assuming the responder's HTTP call stopped — only a cooperative cancel$ does that.
Firing queries whose classes are not registered yet.
07 September 2026