In the first two articles, we saw how an event bus can completely decouple components for one‑way notifications: a button clicks, a badge updates, a toast appears – no shared service, no parent component, no store.
But real applications need more than just "fire and forget". Often, one part of the system must ask another part for data – and wait for a response.
- “Load the current user’s profile.”
- “Save this form and return the new ID.”
- “Check if the user has permission to perform this action.”
Traditionally, we handle such requests with direct service injection. A component imports a UserService, calls loadProfile(), and subscribes to the returned Promise or Observable. This works – but it creates a hard dependency. The component knows exactly which service it is calling, and that service often brings along its own dependencies (HTTP client, cache, other services). Testing becomes a chain of mocks, and refactoring means touching every caller.
What if the same decoupling that worked for notifications could also work for request‑response? What if a component could simply ask a question into the bus, without knowing who answers – and get a typed answer back?
This is the callback message pattern. It turns the event bus into a lightweight, decoupled RPC mechanism. The publisher sends a message and receives an Observable<T> (or Promise<T>) with the result. Somewhere else, a handler receives the same message, does the work, and calls finish(result). The two sides know each other only by the message type – nothing else.
The Traditional Tight Coupling
Let’s start with a typical scenario: a user avatar component needs to display the current user’s name and picture.
Approach 1: Direct Service Call
@Component({...})
export class UserAvatarComponent {
public profile?: UserProfile;
constructor(private userService: UserService) {}
ngOnInit() {
this.userService.loadProfile().subscribe(profile => {
this.profile = profile;
});
}
}
The UserService might inject HttpClient, a cache, and perhaps an auth token. The component is now tightly coupled to UserService. To test this component, you must mock UserService and its loadProfile() method – including any complex return types and error behaviour. If later you decide to change how the profile is loaded, often you have to modify every component that uses this service.
Approach 2: Shared Service with Subjects
Another common pattern is to have a central DataService that holds BehaviorSubject or ReplaySubject and exposes loadProfile() as a method that updates the subject. This still couples the component to a specific service, and the service becomes a dumping ground for everything – loadProfile, loadOrders, savePreferences – violating the Single Responsibility Principle.
The root problem
In all these approaches, the caller knows who provides the answer. This knowledge creates a dependency that makes the code rigid, hard to test, and difficult to evolve.
Decoupling with a Callback Message
Now imagine that the UserAvatarComponent does not know any UserService. Instead, it knows only two things:
- The event bus (
postboy). - A query message – a plain TypeScript class that describes the request.
// The component only knows the message type
import { LoadUserProfileQuery } from '../messages/queries/load-user-profile.query';
@Component({...})
export class UserAvatarComponent {
public profile?: UserProfile;
constructor(private postboy: AppPostboyService) {}
ngOnInit() {
this.postboy.fireCallback(new LoadUserProfileQuery()).subscribe(profile => {
this.profile = profile;
});
}
}
Somewhere else – in a completely different module – a handler listens for this message and provides the answer.
// Inside a feature module's initialisation service
postboy.sub(LoadUserProfileQuery).subscribe(async (msg) => {
const profile = await userRepository.getCurrentUser();
msg.finish(profile);
});
The component does not import UserRepository, or any domain service. It only imports the message class. The handler does not know about the component – it only knows the message. They are decoupled through the bus.
Visualising the Flow
The following sequence diagram shows the complete request‑response flow:
No arrow goes directly from caller to handler. The bus is the only mediator.
Why This Changes Everything
1. Zero coupling to concrete services
The UserAvatarComponent can now be placed in any module, even a shared library, without dragging along the UserService. It only depends on the bus (which is a single, well‑known service) and the message class (a lightweight, side‑effect‑free DTO).
2. Multiple handlers can coexist
You could have two different implementations of the same query – one for development (mocked data) and one for production. The bus can decide which handler to use based on configuration. Or you could have a fallback handler. The caller does not need to change.
3. Testing becomes trivial
To test the UserAvatarComponent, you no longer mock UserService and its dependencies. You only mock the bus:
const mockPostboy = {
fireCallback: jasmine.createSpy().and.returnValue(of(mockProfile))
};
const component = new UserAvatarComponent(mockPostboy as any);
component.ngOnInit();
expect(component.profile).toBe(mockProfile);
The test is clean and fast.
4. Cross‑module communication without direct imports
In a large application with dozens of lazy‑loaded modules, Module A can send a query that is handled by Module B without Module A even knowing that Module B exists. This breaks circular dependencies and speeds up build times.
5. Natural error propagation
If the handler fails, it can call finish(error) or throw an exception. The bus forwards the error to the caller’s observable, exactly like a rejected promise.
Real‑World Example: Form Submission with ID Return
Consider a “Create Order” form. When the user submits, the order must be saved, and the server returns a new order ID. The component then navigates to the order detail page.
Without a bus (typical approach):
@Component({...})
export class OrderFormComponent {
constructor(private orderService: OrderService, private router: Router) {
}
onSubmit() {
this.orderService.createOrder(this.formData).subscribe(orderId => {
this.router.navigate(['/orders', orderId]);
});
}
}
With a callback message:
@Component({...})
export class OrderFormComponent {
constructor(private postboy: AppPostboyService) {}
onSubmit() {
this.postboy.fireCallback(new CreateOrderCommand(this.formData)).subscribe(orderId => {
this.postboy.fire(new NavigateCommand(['/orders', orderId]));
});
}
}
The handler (inside an OrdersModule) registers:
postboy.sub(CreateOrderCommand).subscribe(async (msg) => {
const orderId = await ordersApi.create(msg.data);
msg.finish(orderId);
});
The OrderFormComponent does not import OrdersApi or OrderService. It lives in a different bundle. The two parts are decoupled. You could even replace the entire order creation logic with a different implementation without touching the form.
Automatic Cleanup: The Real Superpower
In traditional service‑based code, forgetting to unsubscribe from an observable causes a memory leak. The subscription stays alive forever (or until the page reloads), keeping references to DOM elements or component instances. This is a common source of bugs.
With an event bus that supports namespaces or registrators, the problem almost disappears. You don’t need to remember to unsubscribe manually. The bus tracks which subscriptions belong to which scope (a component, a module, a feature). When the scope is destroyed, the bus automatically unsubscribes everything.
How it works in practice
Most event bus libraries – including @artstesh/postboy – provide a way to create a named namespace for a component.
export class UserAvatarComponent implements OnInit, OnDestroy {
private namespace = 'UserAvatar';
ngOnInit() {
// All subscriptions are recorded in the namespace
this.postboy.exec(new AddNamespace('UserAvatar'))
.recordSubject(LoadUserProfileQuery).subscribe(profile => {
this.profile = profile;
});
}
ngOnDestroy() {
// This single call unsubscribes from ALL messages registered in this namespace
this.postboy.exec(new EliminateNamespace('UserAvatar'));
}
}
If you forget to call eliminateNamespace in ngOnDestroy, the namespace still lives. But in a well‑architected application you would use a PostboyAbstractRegistrator that calls down() automatically, or you would rely on framework‑specific hooks. The key point: the bus provides the tooling – it’s not just a raw Subject.
What happens if you forget to clean up?
In a typical service with HttpClient.get().subscribe(), forgetting to unsubscribe means the callback will run even after the component is destroyed, causing errors and memory leaks.
With a namespace‑aware bus, the situation is different. You are not fucused on management of every subscription, but on the lifecycle of whole modules and parts of your application.
You still prefer to unsubscribe significant heavy subs, but also, you know that when a user leaves Admin Page, for example, all the subscriptions of this area are going to be cleaned properly.
Why this is better than manual takeUntil
Manual takeUntil works, but it requires discipline. Every component must create a destroy$ subject, call next() and complete() in ngOnDestroy, and pipe every subscription with takeUntil(this.destroy$). This is repetitive and error‑prone.
With a namespace, you write the cleanup code once per zone, and it handles all subscriptions inside that namespace – past, present, and future. You can add new subscriptions without touching the cleanup logic.
Moreover, namespaces are hierarchical. You can create a namespace for an entire module, and all its components share the same parent namespace. Unloading the module automatically unsubscribes everything inside it.
Real‑world impact
In a large Angular application with dozens of modules and hundreds of components, manual subscription management is a major source of bugs. Switching to a namespace‑aware event bus eliminates an entire class of memory leaks. The bus becomes not just a communication tool, but also a lifecycle management tool.
This is one of the reasons why the event bus pattern, when implemented with scoping, is superior to raw RxJS subjects or shared services. It forces you to think about ownership and cleanup, but then automates it.
In the next article, we will see how PostboyAbstractRegistrator and IPostboyDependingService formalise this pattern even further, making it almost impossible to leak subscriptions.
When to Use Callback Messages (and When Not To)
This pattern is not a replacement for every service call. Use it when:
- You want to decouple the caller from the implementation.
- The same request may be answered by different handlers in different environments (test, staging, production).
- The request crosses module boundaries and you want to avoid direct dependencies.
- You need cancellation or timeout behaviour that works uniformly.
Do not use it for:
-
Synchronous, trivial operations (e.g.,
Math.random()). That adds unnecessary overhead. - High‑frequency requests (e.g., mouse move events) – the observable creation cost is not negligible.
- State that must be globally consistent – a store (Redux, NgRx) is still better for shared state.
Callback messages are ideal for application‑level commands and queries – the things that in a clean architecture would be represented as InputPort or Query objects.
Summary
We started this series with fire‑and‑forget events – a simple way to announce that something happened. Now we have extended the same bus to support request‑response interactions. The component asks a question, and somewhere else an answer is provided, without the two ever knowing each other directly.
The benefits are clear:
- Loose coupling – components depend only on messages, not on service implementations.
- Testability – mock the bus, not a chain of dependencies.
- Flexibility – change the answer provider without touching the caller.
- Cancellation and error handling – built on RxJS, no extra libraries.
This pattern brings the power of event‑driven architecture to the asynchronous, request‑driven parts of your application. It does not replace your state management solution, but it complements it beautifully.
In the next article, we will look at synchronous executors – the third kind of message for operations that must return a value immediately (like validation or permission checks). Together, these three patterns – fire‑and‑forget, callback, and executor – cover almost every communication need in a modern frontend application.
And if you are wondering about the concrete implementation: the examples above use the @artstesh/postboy library, because it provides exactly these three message types out of the box, with full TypeScript support and lifecycle management. But the pattern itself is what matters. You can build your own bus in a few dozen lines of code, or adapt any Pub/Sub library that supports typed messages and observables. The idea is the value, not the tool.

Top comments (0)