Code Flexibility: A Dive into Event-Driven Pattern

/ Work

I have a soft spot for programming paradigms that simplify data management. And no, I'm not talking about dealing with data in databases; I'm referring to managing data at the code level. Whether it's controlling the flow of data or understanding the impact of data changes, I find a certain comfort in it.

Event-driven programming excels at decoupling actions from their effects, making it ideal for data flow and effect. Modularity and code complexity are improved by this decoupling. Triggering and responding to events makes systems modular and maintainable.

 

The beauty of event-driven programming lies in its ability to represent real-world scenarios where actions lead to specific consequences or effects. Whether it's user interactions in a web application, changes in state, or updates in a system, events provide a clean and organized way to handle these scenarios.

 

During this article we'll look at several real-world instances, discuss how developers manage operations, and then introduce event-driven programming.

Now I want to be clear about one thing, Our goal is to develop a basic model within our application without utilising message brokers or event bridges. This technique focuses on establishing a maintainable event-driven system right in your coding to handle application data flow.

 

Let's consider a scenario where we're developing an application that involves managing individuals, projects, and project ownership. In this platform, we have the ability to create, delete, or update a person. Additionally, we can add a person to a project or remove them from a project.

// person operations
createPerson()
removePerson()
updatePerson()

// person-project operations
addPersonToProject()
removePersonFromProject()

Whenever a person is added to a project, our objective is to send an email notification to the project owner. Now, in traditional programming, when a person is added to a project, the process would typically involve something like…

const person = await createPerson()
const personProjectResponse = await addPersonToProject(person)
await sendEmailToProjectOwner(emailOptions, personProjectResponse)

Let's extend the example to include inventory management in a similar fashion to persons. We'll add functionalities to create, delete, and update inventory items. Additionally, we'll enable the capability to add an inventory item to a project.

const inventory = await createInventory()
const inventoryProjectResponse = await addInventoryToProject(inventory)
await sendEmailToProjectOwner(emailOptions, inventoryProjectResponse)

The above strategy has some drawbacks:

  • Asynchronous email sending may be useful, however the current structure may not allow it.
  • If the sendEmailToProjectOwner method parameters change, various codebase locations must be updated.
  • Even if you make the sendEmailToProjectOwner call asynchronous, activities that depend on its response will likely force you to handle the function call synchronously, reducing its benefits.
 

Now, these are just two examples among many, and this pattern can extend to an indefinite number of instances. For instance, in a platform where activities are involved, adding an activity to a person may trigger the need to send an email. Similarly, when dealing with inventory, the addition of a new item could also prompt the requirement to send a notification, and so forth.

 

Before delving into the implementation of the event-driven pattern in the examples we provided, let's take a moment to explore how this pattern is typically implemented.

 

While there are various ways to build an event pattern, the approach I usually adopt doesn't rely on built-in EventListeners or emitters. Instead, it revolves around using an array of functions and their corresponding callbacks. Let’s understand this.

A very basic publisher / subscriber model
A very basic publisher / subscriber model

This fundamental concept is quite straightforward: upon subscription, all the listeners are stored in a map. When an event is triggered, the system retrieves all the listeners from the map and sequentially invokes their corresponding handlers.

Let's illustrate how this would look in TypeScript

let subscriber = new Map();

const subscribe = (event: string, callback: Function) => {
  if (!subscriber.has(event)) {
    subscriber.set(event, []);
  }
  subscriber.get(event).push(callback);
};

const trigger = (event: string, ...args: any[]) => {
  if (subscriber.has(event)) {
    subscriber.get(event).forEach((callback: Function) => callback(...args));
  }
};

With this you can significantly simplify our use case by…

subscribe(”ProjectPersonAdded”, async(projectId,person){
	sendEmailToProjectOwner(emailOptions,person)
})

subscribe(”ProjectInventoryAdded”, async(projectId,inventory){
	sendEmailToProjectOwner(emailOptions,inventory)
})

Add the trigger call in each file…

const person = await createPerson()
const personProjectResponse = await addPersonToProject(person)
trigger(”ProjectPersonAdded”, person)
const inventory = await createInventory()
const inventoryProjectResponse = await addInventoryToProject(inventory)
trigger(”ProjectInventoryAdded”, inventory)

This addresses all three of our challenges:

  • It convert’s the email sending to truly Asynchronous.
  • If the parameters of the sendEmailToProjectOwner method change, you only need to update one file.
  • Activities dependent on the response of sendEmailToProjectOwner can now be handled asynchronously.
 

Now, with slight variations, you can construct different subscriber patterns. For instance, you could create one where it listens for all events

subscribe(async (event, payload) => {
    switch (event) {
        case 'ProjectPersonAdded': {
            sendEmailToProjectOwner(emailOptions,payload.person)
            return
        }
        case 'ProjectInventoryAdded': {
            sendEmailToProjectOwner(emailOptions,payload.inventory)
            return
        }
    }
})
 

One common requirement is to unsubscribe from events on demand. This can be achieved with a simple addition to your listener code.

let subscriber = new Map();

const subscribe = (event: string, callback: Function) => {
  if (!subscriber.has(event)) {
    subscriber.set(event, []);
  }
  subscriber.get(event).push(callback);
  return () => {
    subscriber.delete(event);
  };
};

const trigger = (event: string, ...args: any[]) => {
  if (subscriber.has(event)) {
    subscriber.get(event).forEach((callback: Function) => callback(...args));
  }
};

To use this…


const unsubscribe = subscribe(”ProjectPersonAdded”, async(projectId,person){
	sendEmailToProjectOwner(emailOptions,person)
})

Call unsubscribe() when you want to unsubscribe the event

In conclusion, event patterns provide a straightforward and effective way to manage the flow of data. Their simplicity contributes to a more manageable and modular codebase, offering flexibility and ease of maintenance in various applications.

 
Mahendra Rathod
Developer from 🇮🇳
@maddygoround
© 2024 Mahendra Rathod · Source