Introduction to Event-Driven Architecture in Node.js Typescript
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.