iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔔

Designing a Versatile Notification System

に公開

I've accumulated some know-how regarding notifications, so I'm organizing it here.

Types of Notifications

There are two types of notifications: Push notifications and Pull notifications. Push notifications are things like smartphone alerts, while Pull notifications are notifications displayed when you access a site. Pull notifications are often represented by a bell icon.

In this article, I will mainly talk about Pull notifications. I haven't implemented Push notifications myself, so I'm not entirely sure about them, but some of this content might be applicable there too.

The following explanation uses an article-posting app like Zenn as an example.

DB Design

The DB design for Pull notifications is as follows.

  • destinationUserId
  • message
  • url
  • createdAt
  • eventType

destinationUserId is the ID of the user to whom you want to display the notification.
message is the actual content shown in the notification.
url is the destination page when the notification is clicked (apparently called a deep link).
eventType is the type of event that triggered the notification. I'll explain events in detail later, but for example, if someone likes an article, something like "article.like.added" would be stored.

If you feel uneasy about storing a URL in the backend, please check the column at the end of the article. I felt the same way.

API Design

The notification API will be fetch-only. This is because saving notifications is not done via an API but is executed as a side effect in an event-driven manner after something else happens.

Fetching Notifications

When a user logs in, the front end triggers the notification API. The notification API retrieves and displays notification data where the destinationUserId matches the logged-in user.

Saving Notifications

For example, suppose you want to save a notification saying "User X liked your post" to the notification table. You can simply create and save the destinationUserId, message, and URL asynchronously at the end of the "Like" API process.

Adding the Concept of Events to Existing APIs

I think notifications are a type of event sourcing. Instead of events, you are just saving a message and a destinationUserId. Even if you don't go full event sourcing, incorporating parts of that mindset makes it easier to build notifications.

Example: API for Liking an Article

Let's say there is a "Like" API. For example, an HTTP request like this:

// #PUT example.com/articles/:articleId/like

// Request body
{
  makeLike: boolean; // true to like, false to unlike.
}

And suppose calling this API triggers the addLike method in ArticleService. It might look something like this:

ArticleService.ts
ArticleService {
  addLike: (args: {
    articleId: number,
    requestUser: user,
  }) => {
    // Retrieve the article based on the article ID.
    const article = ArticleRepository.findById(args.articleId);
    // Add a like to the article.
    article.addLike(args.requsetUser.id);
    // Save the article.
    ArticileRepository.save(article);
  }
}

ArticileRepository is for saving to the article database.

Now, suppose you want to notify the author of the article when it is liked. You can execute a domain event at the end of the ArticleService.

ArticleService.ts
import { doArticleLikeAddedFlow } from './doArticleLikeAddedFlow';
import { ArticleLikeAdded } from './articleLikeAdded';

ArticleService {
  addLike: (args: {
    articleId: number,
    requestUser: user,
  }) => {
    // Retrieve the article based on the article ID.
    const article = ArticleRepository.findById(args.articleId);
    // Add a like to the article.
    article.addLike(args.requsetUser.id);
    // Save the article.
    ArticileRepository.save(article);
    
    // Execute the domain event asynchronously.
    doArticleLikeAddedFlow(new ArticleLikeAdded({
      article: article,
      likeUser: requsetUser,
    }));
  }
}

doArticleLikeAddedFlow is a function that groups together actions to be performed when ArticleLikeAdded is called. ArticleLikeAdded is what's called a domain event, representing the event of "liking" as a class. This will be used within doArticleLikeAddedFlow.

While I'm using a function call for doArticleLikeAddedFlow here, it could be an external messaging system, a custom pub/sub library, or anything else.

doArticleLikeAddedFlow.ts
import { ArticleLikeAdded } from './articleLikeAdded';
export const doArticleLikeAddedFlow = (event: ArticleLikeAdded) => {
  // Write the logic to save to the notification table here
  // destinationUserId
  const destinationUserId = event.article.creatorId;
  // message
  const message = `${event.likeUser.fullName} liked your post!`;
  // url
  const url = `/articles/${event.article.id}`;
  
  // Save
  NotificationRepository(new Notification({
    destinationUserId: destinationUserId,
    message: message,
    eventType: event.eventType,
    createdAt: event.createdAt,
    url: url,
  }))
}
articleLikeAdded.ts
import { DomainEvent, EventType } from './domainEvent'
class ArticleLikeAdded implements DomainEvent {
  private eventType: EventType;
  
  private createdAt: Date;
  
  private article: Article;
  
  private likeUser: User;
  
  constructor(args: { article: Article; likeUser: User; }){
    this.eventType = 'article.like.added';
    this.createdAt = new Date();
    this.article = args.article;
    this.likeUser = args.likeUser;
  }
}
domainEvent.ts
type EventType = 'article.like.added' | 'article.like.removed'

export interface DomainEvent {
  eventType: EventType;
  createdAt: Date;
}

Event types are unique for each event. The naming convention I use is:
Noun + Past Participle
If resources are nested, connect them with dots.

I found Stripe's handling of events to be quite elegant in this regard.
https://stripe.com/docs/api/events

Column: Isn't it out of scope to have URLs (which are the frontend's responsibility) on the backend?

When the frontend and backend are separated, I felt a sense of incongruity about holding links for clicked notifications in the database. I believe that what kind of URL structure the app has is a territory unknown to the backend and is the responsibility of the frontend. However, with this design, the backend knows the frontend's URL structure. Is this really okay?

Actually, the first design I tried was not this one. I stored the event along with the metadata that makes up the event in the DB, passed it to the frontend, and had the frontend create the URL based on that data. The DB structure looked like this:

  • destinationUserId
  • metadata (stored as JSON)
  • createdAt
  • eventType

The hard part about this was that since metadata is JSON, it was difficult to manage as a schema. When it's hard to manage as a schema, if the data structure of a resource composing the event changes, the structure of the new metadata JSON might also change. Additionally, you would have to manage separately which events have which metadata (I was creating classes for metadata). Since that became quite a burden, I stopped using that method. Rather than reconstructing it later, it is much easier to store what was constructed from the start.

Bonus

I wrote an article about an implementation example of domain events in NestJS!
https://zenn.dev/dove/articles/40476144e8bf9a

Discussion