Loosely coupled communication between services in TypeScript based projects

Oleg Varaksin
6 min readDec 9, 2023
Photo by Pavan Trikutam on Unsplash

Recently in one of my Angular / TypeScript projects, I wanted to implement an event based communication between several service classes, called handlers in my project. There are a lot of handlers which need to interchange data. I didn’t want to inject them into each other — it is a bad design which leads to a not well-arranged application and circular dependencies. Handlers should not know each other. The handler classes in my project are registered as multi providers under an InjectionToken named as ELEMENT_HANDLER. Short excerpt:

@Component({
...
providers: [
{
provide: ELEMENT_HANDLER,
useClass: EdgeHandler,
multi: true
},
{
provide: ELEMENT_HANDLER,
useClass: MainSignalHandler,
multi: true
},
{
provide: ELEMENT_HANDLER,
useClass: PerronHandler,
multi: true
},
...
]

I thought, a kind of event bus and a loosely coupled communication like in Sping framework, would be great.

In Spring, you can publish an event with payload and all methods annotated with @EventListener and having the same event type in the method signature can receive it. You can read e.g. in this article how it works.

How to facilitate a similar approach in TypeScript? In TypeScript, we can write custom Decorators! What we will need, is a smart parameter decorator which will notice a class, a method and a type of event class from the place where it appears. Such mapping information should be registered in a singleton object to be able to access it anywhere. A parameter decorator is declared before a parameter declaration. The communication pattern we would like to use, is demonstrated in the following code snippet.

// 1. handler
await SspElementMediator.getInstance().publish(
new TrainPositionSelectedEvent(trainNumber)
);

// 2. handler
async onTrainPositionSelected(
@SspElementEvent event: TrainPositionSelectedEvent): Promise<void> {
// do something with event
}

Another example where the event listener returns a value to the caller looks like as follows (yes, it’s not only “fire-and-forget” communication):

// 1. handler
const selected = await SspElementMediator.getInstance()
.requestAndTakeFirstValue<boolean>(
new IsTrainPositionSelectedEvent(trainRun.trainNumber)
);

// 2. handler
async onIsTrainPositionSelected(
@SspElementEvent event: IsTrainPositionSelectedEvent): Promise<boolean> {
// do something with event
return true / false;
}

As you could see, we want to implement the decorator @SspElementEvent. For the methods which act as event listeners, the return type Promise is expected. In your projects, you can define any other types. An example of the event classes:

export class TrainPositionSelectedEvent extends AbstractSspEvent {
constructor(public trainNumber: string | undefined) {
super();
}
}

export class IsTrainPositionSelectedEvent extends AbstractSspEvent {
constructor(public trainNumber: string) {
super();
}
}

To achieve our intention, we need Metadate Reflection API to determine information on used types at runtime. Angular projects already have this dependency because Angular uses decorators intensively. That means, we don’t need to install reflect-metadata. We only need to import “reflect-metadata”, e.g. in the file where we are going to write a parameter decorator.

import "reflect-metadat";

Ensure that you also have these two options in your tsconfig.json file:

{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

With these preparations, additional design-time type information will be exposed at runtime. The type information can be accessed via Reflect.getMetadata for design:type, design:paramtypes, and design:returntype.

Decorators are just plain functions or so-called decorator factories, if we need to pass in some values from outside — read the official TypeScript documentation.

Let’s write our parameter decorator.

/**
* TypeScript decorator to annotate a method parameter in SSP element
* handler if the method should act as event listener. The event listener
* method should return a Promise, otherwise the event binding will not
* be registered.
*/
export function SspElementEvent(target: object, methodName: string) {
// use reflection to determine the type of method parameter
const paramTypes: any[] = Reflect
.getMetadata("design:paramtypes", target, methodName);
if (paramTypes.length != 1) {
console.warn(`Event listener ${target.constructor.name}#${methodName} \
may only have one method parameter`);
return;
}

// validate return type
const returnType = Reflect
.getMetadata("design:returntype", target, methodName);
if (!isPromiseLike(returnType)) {
console.warn(`Event listener ${target.constructor.name}#${methodName} \
may only have Promise like return types`);
return;
}

const eventName = paramTypes[0].name;
SspElementMediator.getInstance()
.putEventBinding(eventName, target.constructor.name, methodName);
}

/**
* This approach is called duck typing. We basically say:
* "if the value is an object and has these specific properties/methods,
* then it must be a promise".
*/
const isPromiseLike = (p: any) => p &&
(typeof p === 'object' || typeof p === 'function') &&
typeof p.resolve === 'function' && typeof p.reject === 'function';

What we do this — we create a mapping (or event binding) between class + method and the event type. This is accomplished by invoking putEventBinding on the SspElementMediator. The mediator is a behavioral design pattern that lets you reduce chaotic dependencies between objects. The pattern restricts direct communications between the objects and forces them to collaborate only via a mediator object.

The decorator function is executed quite early — before all Angular’s initialization jobs. So, at this time we can not access the handlers with the InjectionToken, and can not set them into the SspElementMediator. We can do it inprovide within useFactory.

{
provide: SspElementMediator,
useFactory: (elementHandlers: SspElementHandler<any>[]) =>
SspElementMediator.getInstance().withElementHandlers(elementHandlers),
deps: [ELEMENT_HANDLER]
}

Now, we can inject SspElementMediatorin any component, service, etc. where we want. Let’s look at the central class SspElementMediator. The method putEventBinding called from the decorator function is pretty simple.

export class SspElementMediator {

private static _instance: SspElementMediator;

private readonly _eventBindings:
Map<string, ClassMethodBinding[]> = new Map();
private _elementHandlers?: SspElementHandler<any>[];

private constructor() {
}

/**
* Returns a singleton instance.
*/
static getInstance(): SspElementMediator {
if (!SspElementMediator._instance) {
SspElementMediator._instance = new SspElementMediator();
}
return SspElementMediator._instance;
}

/**
* Creates an event binding, i.e. associates given event's class name
* with the given class method.
*/
putEventBinding(eventName: string,
className: string,
methodName: string) {
let classMethodBindings = this._eventBindings.get(eventName);
if (classMethodBindings === undefined) {
classMethodBindings = [];
}
classMethodBindings.push({className, methodName});
this._eventBindings.set(eventName, classMethodBindings);
}
}

There three methods in SspElementMediator which are placed at disposal for callers:

/**
* Publishes given event to all SSP element handlers which are able
* to handle it according to the event binding. It works as
* "fire-and-forget" for emitters which are not interested
* in return values.
*/
async publish(event: AbstractSspEvent): Promise<void> {
const classMethodBindings = this._eventBindings
.get(event.constructor.name) ?? [];
for (const classMethodBinding of classMethodBindings) {
const elementHandler = this.findElementHandler(
classMethodBinding.className) as any;
if (elementHandler !== undefined) {
await (elementHandler[classMethodBinding.methodName](event)
as Promise<any>).catch(err => console.error(
`Error while invoking ${classMethodBinding.className}# \
${classMethodBinding.methodName} with event \
${JSON.stringify(event)}: ${err}`));
}
}
}
/**
* Sends given event to all SSP element handlers which are able to handle
* it according to the event binding and returns the first promise with
* a return value. The return value can be undefined if the called event
* listener method throws an exception or the event listener for the
* given event was not found. Most of the time we want a peer-to-peer
* communication between handlers, so that this is a useful method
* in such case.
*/
async requestAndTakeFirstValue<T>(event: AbstractSspEvent):
Promise<T | undefined> {
const classMethodBindings = this._eventBindings
.get(event.constructor.name) ?? [];
if (classMethodBindings.length > 0) {
const {className, methodName} = classMethodBindings[0];
const elementHandler = this.findElementHandler(className) as any;
if (elementHandler !== undefined) {
try {
return elementHandler[methodName](event);
} catch (err) {
console.error(`Error while invoking ${className}#${methodName} \
with event ${JSON.stringify(event)}: ${err}`);
return undefined;
}
}
}
return undefined;
}
/**
* Sends given event to all SSP element handlers which are able to handle
* it according to the event binding and returns a promise with array
* of EventListenerResult objects. Every EventListenerResult describes
* a return result from the appropriate event listener the given event
* was handled by.
*/
async request<T>(event: AbstractSspEvent):
Promise<EventListenerResult<T | undefined>[]> {
...
}

As you noticed, we get the name of the event class by calling even.constructor.name. The same technique was also used in the decorator — there we got the class name by calling target.constructor.name.

Last but not least, the method findElementHandler called within the three methods above.

private findElementHandler(className: string):
SspElementHandler<any> | undefined {
for (const elementHandler of this.getElementHandlers()) {
if (className === elementHandler.constructor.name) {
return elementHandler;
}
}
return undefined;
}

For the sake of convenience, I skipped some complicated details. Try to write them by yourself as exercise :-). Any suggestions, feedback and comments are welcome!

--

--

Oleg Varaksin

Thoughts on software development. Author of “PrimeFaces Cookbook” and “Angular UI Development with PrimeNG”. My old blog: http://ovaraksin.blogspot.de