Adding a Pub/Sub layer to your express backend can add an event-driven capability that makes handing certain operations more intuitive as well as provide better code separation.
Sometimes we might want to perform some actions or call third party services as a result of an event occurring in our app. For example sending a welcome email, a welcome sms or analytics data when a new user is registered which is very common in most apps these days.
Lets take the aforementioned example where we send email, sms and analytic data when a user registers. Traditionally this can be done by using imperative function calls as shown in the example below.
//auth.service.ts
import EmailService from './services/mail.service';
import SMSService from './services/sms.service';
import AnalyticsService from './services/analytics.service';
//...other imports
class AuthService {
public async signup(userData): Promise<User> {
const findUser: User = await User.findOne({ where: { email: userData.email } });
if (findUser) throw new Error(`Email ${userData.email} already exists`);
const hashedPassword = await bcrypt.hash(userData.password, 10);
const createdUser: User = await User.save({ ...userData, password: hashedPassword });
//Some actions
AnalyticsService.addUserRecord({email:createdUser.email, number:createdUser.number});
EmailService.sendWelcomeEmail(createdUser.email);
//...Other user sign up actions
SMSService.sendWelcomeSMS(createdUser.number);
return createdUser;
}
}
You can already see how this code will look like as we keep adding more actions, each action will add another imperative function call to a dependent service and the function will keep growing in size. You can also see that besides being hard to maintain, this approach violates the Single Responsibility Principle as well as has the potential for repetition across different events not only user registration.
Pub/Sub Layer
Adding a Pub/Sub layer can solve this problem by emitting an event (user registered with this email) and letting separate listeners handle the work.
We will utilize Node.js's Event Emitter to do that.
First we will create a shared Event Emitter as well as specify the set of events we need.
//eventEmitter.ts
import { EventEmitter } from 'events';
const Events = {
USER_REGISTRATION = 'user-registered',
}
const eventEmitter = new EventEmitter();
export { eventEmitter, Events };
Note: Due to Node.jS Caching, this will always return the same instance of eventEmitter (Singleton)
Now we can modify our code to emit a "user registration event"
//auth.service.ts
import { eventEmitter, Events } from '../common/utils/eventEmitter';
//...other imports
class AuthService {
public async signup(userData): Promise<User> {
const findUser: User = await User.findOne({ where: { email: userData.email } });
if (findUser) throw new Error(`Email ${userData.email} already exists`);
const hashedPassword = await bcrypt.hash(userData.password, 10);
const createdUser: User = await User.save({ ...userData, password: hashedPassword });
//Emit User Registration Event
eventEmitter.emit(Events.USER_REGISTRATION,{ email: userData.email, number: userData.number });
return createdUser;
}
}
Now Separate Services can listen on events and do their Job, For example the EmailService
//email.service.ts
import MailGunClient from '../common/clients/mailGun.client';
import EmailClient from '../common/interfaces/emailClient.interface';
import { eventEmitter, Events } from '../common/utils/eventEmitter';
class EmailService {
constructor(private emailClient: EmailClient = new MailGunClient()) {
this.initializeEventListeners();
}
private initializeEventListeners(): void {
eventEmitter.on(Events.USER_REGISTRATION, ({ email }) => {
this.emailClient.sendWelcomeEmail(email);
});
}
}
export default EmailService;
Now all that is left is to create an instance of your event listening services when bootstrapping your express app to initialize their listeners, something like calling this function when initializing your app
private initializeServices() {
new AnalyticsService();
new EmailService();
new SMSService();
}
You can already see how adding more actions won't add any extra lines of code in the user registration function which provides code separation and embraces the event driven nature of Node.js.
Top comments (5)
Great article Raggi, clear and concise. Thanks for writing.
How would you define what parameters the event is expecting with typescript?
take a look at this
basarat.gitbook.io/typescript/main...
this is nice, I've always liked event-driven architectures for game development but never tried to use in node
Hey, what if the event handler failed? won't you have lost the oportunity to handle this? I dont believe it can be considered a reliable way of managing side effects... what do you think?
yeah that's a very valid point, i agree with you about the inability to catch downstream errors.
probably it can be more suitable to use this for safer side effects