Back in the day, when applications were simple (monolithic) and everything happened on the screen, communicating with users was pretty straightforward. For example, when an error occurred, a simple error page had to be shown. But nowadays, in the era of more complex solutions that handle millions of concurrent users, many more thing happen and can be easily missed.
That’s why the IT world introduced notifications.
Almost all modern applications, regardless of their purpose or complexity, try to implement notification systems to stay in touch with users. However, in order to have notifications, you need to know how to build them into your application’s ecosystem.
One approach is to allow the notifications service to know about everything that happens in your application. For every action that the user takes, each tiny request needs to be routed to your notifications hub and dealt with. Your application is being looked at from above and judged if something is worthy of attention.
The upside of this solution is that, if implemented correctly, none of your components needs to know that there is one entity that listens to all their traffic and takes actions based on their input. There is no distributed logic, just a single place that holds all the rules to transform all kinds of data into user-oriented messages.
This approach is very good for highly asynchronous systems that already use event-driven architecture to broadcast their components’ data across the application. It doesn’t matter if this is done through a single ESB or bunch of Kafka topics, it is possible to just plug in another program to the other side and read the events stream which goes through.
This gives the consumer (notifications) the ability to act, oblivious to all the producers (and cultivates pub-sub pattern), and the producers the peace of mind not to worry about another system which wants to consume their data.
Other services that can communicate through an API can still post messages to notifications, albeit more consciously, if the right endpoint will be exposed. This means that notifications can do their work mostly uncoupled and choose the rate of their processing. If they’re low on resources, then the execution will slow down, but the requests will never be lost unless they’re synchronous.
A major drawback of this solution is the complexity and size of the notifications themselves. As a primary focal point, they have to know about every message’s structure and data, how to process and report it using selected channels. It puts a lot of pressure on a single system and can make scaling it a real pain. Let’s not forget that the notifications service needs to be aware of every new component which will be introduced later and keep up to date with all the changes across your application. Sometimes, this means even doubling the efforts needed to implement new features. It might become a bottleneck if not considered carefully.
The Oblivious Postman
The second approach is to swap the roles. Now, your notifications service is a simple medium to transport messages that they are given. All of the work lies in producers who need to decide what and how will they broadcast to the users. The notifications service becomes the application’s mailman, waiting for new messages to deliver.
This way, it’s easier to add features to the code. All it takes is to include a single call to the notifications service and the user will be notified when it is executed. Every service can choose their own message and medium to notify users about their actions. The notifications service acts only as a transport protocol between users and the application. Managing the details of the notification is closely tied to the features they describe. It is hard to forget to change something when you are modifying the features as the code is right next to each other.
The downside of this solution is the growing complexity of tracing all the notification cases across multiple services. Additionally, this solution is tying all of the services together, so they must now know about the notifications service.
Apart from that, you have a steady queue of requests (if you chose synchronous protocol) which has to be read and answered. This puts a strain on the notifications as they cannot choose the rate of their work. The requests need an immediate response to notify the sender that the action succeeded. On the other hand, every implementing service now has to do additional work which slightly delays their response. These responses are more important because they are usually business-related. Besides, it puts additional strain on the services.
Neither solution comes without a cost. When evaluating all the options, it is important to consider all of the variables and decide on a better approach. It is also important not to restrict yourself. Go play with them a little bit and see how the system reacts. If it’s going well, then it is time for fine-tuning. If not, then maybe you should think about another approach.
Forging The Path
Now that we’ve talked about higher-level designs, it is time to get our hands dirty. After all, in building a notifications service, one does have to make a couple of common decisions that will shape the details of the service.
Probably the most important of all features. When done properly, configuration can significantly reduce the amount of needed maintenance windows and outages. The ideal solution will allow you to make changes without any downtime.
The simplest approach is to put all properties into files and store them somewhere outside of the application (S3 buckets, external CDN, git) with easy access during application startup. When something changes, all it takes is to tell your application that there’s a new config (app refresh) or just redeploy it if it’s quick to start.
Most sophisticated solutions involve some sort of application-managed storage. Ultimately all the notification properties can be kept in a separate database and accessed each time something happens (caching is highly recommended). It is important to draw a line between flexibility and performance. Adding more external calls will slow down the code but the result will be fully real-time, as the configuration switches without any downtimes or delays.
Additionally, if the notification context has to be configured per a single user it is unavoidable to add some sort of persistent storage as it will be very tedious to manage multiple files. They will either grow in their size or their numbers.
It is a key aspect to consider when and for how long the notifications should be persisted. Do you keep all the notifications to present them to users in a form of activity history or do you persist only the most important ones (security-related, requiring interaction, etc.)?
Another aspect is the chosen storage technology. If you thought about all your future needs, it might be wise to use a standard relational database. On the other hand, if you like prototyping and you expect your data model to change a lot, it might be wiser to turn into schema-less databases like MongoDB or cloud-specific (DynamoDB, Google Datastore, etc.). Regardless of the chosen approach, all of the collected properties need to be properly described and mapped to both their usage and source. This will prevent the collection of unneeded data.
Do you offer your application in multiple languages? If so, then it would be a shame if all users had to read their notifications in English. Consider plugging in i18n in the early stages of development and try to build it into your data model (like you probably did across your application).
It is worth considering what you’d like to show a user when the defined message changes. It’s not good practice to change something the user has already seen, so your application has to support multiple versions of the same message. This can be achieved via notification versioning or simply storing all the translation keys along with the notifications themselves. At the cost of additional storage, you receive seamless experience across all your user base.
Sometimes notifications can lead to the other parts of the application. Other times they can contain important details that help understanding their context. On special occasions (e-mail notifications) you will want to show something more than just a text. Be it just a simple placeholder replacement or a fully-fledged HTML-rendered template, the notifications service is in a need of templating engine.
Regardless of the chosen technology, there’s a multitude of different frameworks that offer content generation from predefined templates. It is more important to know how to interact with them. They can be again, like configurations, just an internal file, loaded upon startup and held inside the memory. External file in a bucket is a way to go for real-time experience (see configurations). Most often we do not want to hardcode any application URLs as they are prone to change. It would be highly beneficial to plug in an URL resolver (if we use any, e.g. Kubernetes internal DNS system) for the ability to find correct routing all the time.
In terms of internationalization and templating, it is also very important to consider where the execution will happen. Would you rather have a full-stack API which returns complete and translated data or DIY endpoints which rely on recipients to translate the content for themselves?
Not all actions need to end up in a user’s mailbox, but all of them should be reported somehow. The notifications service will not be complete without a full spectrum of possible mediums. If the user is spending a lot of the time online, maybe it is worth to detect when he’s on and treat him to a simple toast inside the application. When the user becomes offline (logs out, closes the browser, exits the app) the app could switch to external pipes (e-mail, SMS). It is worth considering future needs. If you’re going to build a new mobile app, prepare your service to also stream push notifications. Later on, you can just enable this option after you launch your client app.
Regardless of the mode, always give your users options to opt-in and opt-out from different notification types and transportation modes atomically.
The above designs and components are not the exclusive architectures one has to follow to enable user notifications in their application. They are, however, a good base of things to consider when you start thinking about building one.
There are multiple ways to achieve the ultimate solution. However, no solution will be build without detailed business and subject knowledge. Knowing your strengths and weaknesses is a key to understanding the final shape of your solution. Not everybody has to talk to their users, but when they do, it needs to be reliable.