Introduction to Event-Driven Architecture in Node.js Typescript

Chanchai Lee
ABACUS digital
Published in
4 min readFeb 10, 2023

Event-driven architecture is a popular software design pattern that enables a system to react to various events that occur in the application. In this pattern, there are two types of objects: event emitters and event listeners. Event emitters trigger events, while event listeners listen and react to those events.

This article will provide an overview of how to implement a basic event-driven architecture using Node.js’ EventEmitter and TypeScript. We'll also explore how to create an event publisher/subscriber system, where an event publisher is an object that emits events, and an event subscriber is an object that listens and reacts to those events.

Implementing the Event-Driven Architecture

First, let’s create an interface ISubscriber that defines the subscribe method that an event subscriber should implement. The subscribe method takes two parameters: event (a string) and executor (a function that takes a payload and returns a value or a Promise).

interface ISubscriber {
subscribe(
event: string,
executor: (payload: any) => any | Promise<any>,
): this;
}

Next, let’s define the OrderEvent type, which is an enumeration of all the possible events that an order can emit.

export type OrderEvent = 'order-created' | 'order-paid';

Now, we’ll create a class OrderPublisherSubscriber that implements the ISubscriber interface and provides the ability to both subscribe to and publish events. This class takes an instance of EventEmitter in its constructor, and in its subscribe method, it listens for the specified event and calls the executor function with the event's payload when the event occurs.

export class OrderPublisherSubscriber implements ISubscriber {
constructor(private readonly emitter: EventEmitter) {}
subscribe(event: string, executor: (payload: any) => any): this {
this.emitter.on(event, executor);
return this;
}

publish(event: OrderEvent | (string & {}), payload: any): this {
this.emitter.emit(event, payload);
return this;
}
}

Finally, we’ll create the Order class, which represents an order in our system. This class has three properties: id, name, and products.

export class Order {
constructor(
private readonly _id: number,
private readonly _name: string,
private readonly _products: any[],
) {}

get id() {
return this._id;
}

get name() {
return this._name;
}

get products() {
return this._products;
}
}

Putting it all together

Now that we have all the necessary components, let’s create a executeOrder function that demonstrates how to use the OrderPublisherSubscriber class to implement an event-driven architecture.

const executeOrder = () => {
const emitter = new EventEmitter();
const orderPubSub = new OrderPublisherSubscriber(emitter);

orderPubSub
.subscribe('order-created', (order: any) => {
console.log('Order created', { ...order, status: 'created' });
orderPubSub.publish('order-paid', order);
})
.subscribe('order-paid', (order: any) => {
console.log('Order paid', { ...order, status: 'paid' });
orderPubSub.publish('order-finished', order);
})
.subscribe('order-finished', (order: any) => {
console.log('Order finished', { ...order, status: 'finished' });
});

const orders = Array(10)
.fill(null)
.map((_, index) => {
const products = Array(3)
.fill(null)
.map((_, index) => {
return {
id: index,
name: `Product ${index}`,
price: Math.random() * 100,
};
});

return new Order(index, `Order ${index}`, products);
});

orders.map((order) => orderPubSub.publish('order-created', order));
};

The executeOrder function serves as the entry point of our program. It first creates an instance of EventEmitter which serves as a base for our implementation of the Pub/Sub pattern. This instance is then passed as an argument to the OrderPublisherSubscriber class to create a orderPubSub object.

Next, the orderPubSub object calls the subscribe method on itself multiple times to register event listeners for different events, such as 'order-created', 'order-paid', and 'order-finished'. These listeners receive the payload (in this case, an Order object) and perform actions based on the event. For example, when an 'order-created' event is received, the log will output the order object with its status set to 'created', and it will then trigger the publication of an 'order-paid' event.

After registering the event listeners, the executeOrder function creates an array of 10 Order objects, each with a unique ID, name, and array of products. Finally, it triggers the publication of an 'order-created' event for each of the Order objects in the array by calling the publish method on the orderPubSub object.

The final implementation

// filename: order.ts
import { EventEmitter } from 'events';

interface ISubscriber {
subscribe(
event: string,
executor: (payload: any) => any | Promise<any>,
): this;
}

export type OrderEvent = 'order-created' | 'order-paid';

export class OrderPublisherSubscriber implements ISubscriber {
constructor(private readonly emitter: EventEmitter) {}
subscribe(event: string, executor: (payload: any) => any): this {
this.emitter.on(event, executor);
return this;
}

publish(event: OrderEvent | (string & {}), payload: any): this {
this.emitter.emit(event, payload);
return this;
}
}

export class Order {
constructor(
private readonly _id: number,
private readonly _name: string,
private readonly _products: any[],
) {}

get id() {
return this._id;
}

get name() {
return this._name;
}

get products() {
return this._products;
}
}

const executeOrder = () => {
const emitter = new EventEmitter();
const orderPubSub = new OrderPublisherSubscriber(emitter);

orderPubSub
.subscribe('order-created', (order: any) => {
console.log('Order created', { ...order, status: 'created' });
orderPubSub.publish('order-paid', order);
})
.subscribe('order-paid', (order: any) => {
console.log('Order paid', { ...order, status: 'paid' });
orderPubSub.publish('order-finished', order);
})
.subscribe('order-finished', (order: any) => {
console.log('Order finished', { ...order, status: 'finished' });
});

const orders = Array(10)
.fill(null)
.map((_, index) => {
const products = Array(3)
.fill(null)
.map((_, index) => {
return {
id: index,
name: `Product ${index}`,
price: Math.random() * 100,
};
});

return new Order(index, `Order ${index}`, products);
});

orders.map((order) => orderPubSub.publish('order-created', order));
};

executeOrder();

to execute it

ts-node order.ts

The result

This simple example demonstrates how we can use the Pub/Sub pattern in our application to decouple the event trigger from its handler, allowing for a more flexible and scalable architecture.

--

--

Chanchai Lee
ABACUS digital

Head of Application Development at Abacus Digital, Master of Computer Science at UAB (The University of Alabama at Birmingham).