Using Golang to Build a Real-Time Notification System - A Step-by-Step Notification System Design Guide

Using Golang to Build a Real-Time Notification System - A Step-by-Step Notification System Design Guide

·

16 min read

In this article, I aim to provide an in-depth exploration of Golang, focusing on project structure, software principles, and concurrency.

Golang notification system design

Common Challenges for Go Beginners

I've been working on a side project involving real-time notifications to clients. While the project, named Crisp, is too complex for a single article, I'll discuss its main features and delve into various aspects of a multi-core application. Before we proceed, let's address some of the common challenges faced by Go beginners and how to avoid them when implementing a notification system.

Single Responsibility

When starting a new Golang project, it's crucial to consider that Go is a statically typed language. It can be unforgiving if you begin with a poor project structure and insist on continuing with it. Many developers transitioning from dynamic languages face challenges with Go packages and circular dependencies. This often results from not adhering to the single responsibility principle.

A basic notification system consists of three main parts:

  1. Communication between the client and server (WebSocket, HTTP, or gRPC).
  2. Storage to store notifications for access when users come online.
  3. Signaling for clients to receive real-time notifications.

Each of these parts should be a separate package because they have different responsibilities within the system. While it's possible to place them all in a single package, separating responsibilities isn't just about performance; it's about developer efficiency. This separation, coupled with dependency injection, enables fast testing and development of each part of the system, making teamwork and parallel work more manageable.

Dependency Injection

How do these parts interact? They do so through interfaces. When we want a service that uses the storage and signaling packages to build our business logic, it doesn't depend on these packages by their class (or struct in Golang) name. Instead, it depends on them through their interfaces. This approach favors development and testing.

By depending on an implementation of an interface rather than a fully implemented struct, we can easily mock the interface during tests. For example, when working on a service that depends on another service, you can mock the response of that service's endpoint without running an entirely new application along with all its dependencies on your local machine. This separation of concerns enables developers to focus on their specific tasks, making teamwork more efficient.

Let's Write Some Simple Tests

Golang provides a simple yet powerful structure for unit testing. Tests can be written alongside implementations, maintaining code organization. Let's consider an example. We want to create and test a serialization package that retrieves rows from a database (commonly referred to as a repository) and converts those rows into a serializable structure. To achieve this, we've discussed an interface that satisfies our requirements.

package repository

import (
   "context"
   "errors"
)

var (
   ErrNotFound = errors.New("article not found")
)

type Article struct {
   ID      uint64
   Title   string
   Content string
}

type ArticleRepository interface {
   ByID(ctx context.Context, id int) (Article, error)
}

We can store errors and entity definitions like Article in a separate package. For this tutorial, we'll keep them together. Now, let's implement the serializer package, keeping in mind that this implementation may not reflect real-world serializers accurately.

type SimpleSummaryArticle struct {
   ID      uint64 `json:"id"`
   Title   string `json:"title"`
   Summary string `json:"summary"`
   More    string `json:"more"`
}

type Article struct {
   articles          repository.ArticleRepository
   summaryWordsLimit int
}

func NewArticle(articles repository.ArticleRepository, summaryWordsLimit int) *Article {
   return &Article{articles: articles, summaryWordsLimit: summaryWordsLimit}
}

func (a *Article) ByID(ctx context.Context, id uint64) (SimpleSummaryArticle, error) {
   article, err := a.articles.ByID(ctx, id)
   if err != nil {
      return SimpleSummaryArticle{}, fmt.Errorf("error while retrieving a single article by id: %w", err)
   }
   return SimpleSummaryArticle{
      ID:      article.ID,
      Title:   article.Title,
      Summary: a.summarize(article.Content),
      More:    fmt.Sprintf("https://site.com/a/%d", article.ID),
   }, nil
}

func (a *Article) summarize(content string) string {
   words := strings.Split(strings.ReplaceAll(content, "\n", " "), " ")
   if len words > a.summaryWordsLimit {
      words = words[:a.summaryWordsLimit]
   }
   return strings.Join(words, " ")
}

This code retrieves data from the repository and transforms it into the desired format. As you can see, we can test the summarize method effectively. In Golang, tests can be placed in files with a _test.go suffix. For example, if our main file is named article.go, the test file should be named article_test.go. In the test file, we've created a mock for the article repository:

type mockArticle struct {
   items map[uint64]repository.Article
}

func (m *mockArticle) ByID(ctx context.Context, id uint64) (repository.Article, error) {
   val, has := m.items[id]
   if !has {
      return repository.Article{}, repository.ErrNotFound
   }
   return val, nil
}

We can easily use this mock for testing our serializer package:

func TestArticle_ByID(t *testing.T) {
   ma := &mockArticle{items: map[uint64]repository.Article{
      1: {
         ID:      1,
         Title:   "Title#1",
         Content: "content of the first article.",
      },
   }}
   a := NewArticle(ma, 3)

   _, err := a.ByID(context.Background(), 10)
   assert.ErrorIs(t, repository.ErrNotFound, err)

   item, err := a.ByID(context.Background(), 1)
   assert.Equal(t, "https://site.com/a/1", item.More)
   assert.Equal(t, uint64(1), item.ID)
   assert.Equal(t, "content of the", item.Summary)
}

For assertions, we've used the github.com/stretchr/testify/assert package. However, there is an important issue with the code: it doesn't utilize interfaces to describe serializers. If another package needs these serializers, this would require changes. Keep this in mind.

Just hold on with me; we are getting started.

Let's Write a Benchmark

Benchmarking in Golang is simple. Golang provides powerful utilities for writing benchmarks. Benchmarks are placed in the same test files as tests but are prefixed with "Benchmark" and take a *testing.B parameter that contains an N property, indicating how many times the function should be executed.

func BenchmarkArticle(b *testing.B) {
   ma := &mockArticle{items: map[uint64]repository.Article{
      1: {
         ID:      1,
         Title:   "Title#1",
         Content: "content of the first article.",
      },
   }}
   a := NewArticle(ma, 3)

   for

 i := 0; i < b.N; i++ {
      a.ByID(context.Background(), 10)
   }
}

This benchmark evaluates the performance of our serializer. The result shows that it took an average of 15.64 nanoseconds to serialize a row. Let's implement and benchmark the examples from the article "Building an Online Taxi App Like Uber With Golang - Part 3: Redis to the Rescue!"

If posts are stored in an array, the code needs to check nearly every post to find the required ones. In the worst-case scenario, it might require 10,000 comparisons. If the server can handle 1,000 comparisons per second, it would take 10 seconds. Let's magnify the impact.

Storing posts in order and using algorithms like binary search would significantly reduce the number of comparisons required. In the best case, it would take just 100 milliseconds.

If we used a map to store posts, each lookup would be a single instruction, resulting in 10 milliseconds for the entire page.

Here are two algorithms with similar behavior for searching. They take a slice of integers and a number and return either the index of the given number or -1 if it's not found.

// CheckEveryItem looks for the given lookup argument in the slice and returns its index if it is presented.
// Otherwise, it returns -1.
func CheckEveryItem(items []int, lookup int) int {
   for i := 0; i < len(items); i++ {
      if items[i] == lookup {
         return i
      }
   }
   return -1
}

// BinarySearch expects to receive a sorted slice and looks for the index of the given value accordingly.
func BinarySearch(items []int, lookup int) int {
   left := 0
   right := len(items) - 1
   for {
      if left == lookup {
         return left
      }
      if right == lookup {
         return right
      }

      center := (right + left) / 2
      if items[center] == lookup {
         return center
      }
      if center > lookup {
         right = center
      }
      if center < lookup {
         left = center
      }
      if left >= right-1 {
         return -1
      }
   }
}

Both algorithms have similar behavior, and they take a slice of integers and a number as input. You can define a type for this behavior:

type Algorithm func(items []int, lookup int) int

This type allows you to write tests and benchmarks once instead of repeating them for each algorithm. Here's an example of how you can write tests for these algorithms:

func testAlgorithm(alg Algorithm, t *testing.T) {
   items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
   for i := 0; i <= 9; i++ {
      assert.Equal(t, i, alg(items, i))
   }
   assert.Equal(t, -1, alg(items, 100))
}

The function benchmarkAlgorithm is similar, but it performs benchmarking:

func benchmarkAlgorithm(alg Algorithm, b *testing.B) {
   totalItems := int(1e3)
   items := make([]int, totalItems)
   for i := 0; i < totalItems; i++ {
      items[i] = i
   }
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      lookup := rand.Intn(totalItems - 1)
      alg(items, lookup)
   }
}

These benchmark functions make a slice with 1,000 members and search for a random number within that range. The b.ResetTimer() is important to ensure that the creation of a large slice doesn't affect the benchmark result.

Here are the benchmark functions:

func BenchmarkCheckEveryItem(b *testing.B) {
   benchmarkAlgorithm(CheckEveryItem, b)
}

func BenchmarkBinarySearch(b *testing.B) {
   benchmarkAlgorithm(BinarySearch, b)
}

Now, let's run these tests to evaluate the performance of each algorithm. The result shows that the CheckEveryItem algorithm took 143.7 ns/op, while BinarySearch took 58.54 ns/op to complete. However, the purpose of these tests isn't just to save a couple of nanoseconds. Let's increase the size of the slice to one million:

func benchmarkAlgorithm(alg Algorithm, b *testing.B) {
   totalItems := int(1e6)
   items := make([]int, totalItems)
   // (Rest of the code remains the same)
}

With one million items, the CheckEveryItem algorithm took 199 μs/op, while BinarySearch remained in the nanosecond realm with 145.6 ns/op. Let's take it a step further with one hundred million items:

func benchmarkAlgorithm(alg Algorithm, b *testing.B) {
   totalItems := int(1e8)
   items := make([]int, totalItems)
   // (Rest of the code remains the same)
}

Since binary search is a logarithmic algorithm, it completed the benchmark in just 302.6 ns/op. In contrast, CheckEveryItem took significantly longer, with 28 ms/op (28,973,093 ns/op).

This demonstrates the significance of using efficient algorithms for specific tasks. These benchmarks showcase the benefits of choosing the right data structures and algorithms for your application.

Still there? Great, let's move. I know you will miss some things, but you can pick it up, by revisiting that part ;)


Before that, one important point. Similar to this knowledge bomb, I run a developer-centric community on Slack. Where we discuss these kind of implementations, integrations, some truth bombs, weird chats, virtual meets, and everything that will help a developer remain sane ;) Afterall, too much knowledge can be dangerous too.

I'm inviting you to join our community, take part in discussions, and share your freaking experience & expertise. You can fill out this form, and a Slack invite will ring your email in a few days. We have amazing folks from some of the great companies, and you wouldn't wanna miss interacting with them. Invite Form

And I would be highly obliged if you can share that form with your dev friends, who are givers.


Certainly, here's the full content and code for your reference:

Implementing Crisp Notification System

In this tutorial, we will be implementing a notification system called Crisp. The system follows a design where new notifications are sent to a server, stored, and then signaled to the relevant customers. We'll explore the code for Crisp, including the storage package, the entity package, and the signal package, which are the key components of the notification system. We'll also discuss concurrency considerations and demonstrate how to use channels and slices to manage notifications effectively.

Storage Package

The storage package manages the storage of notifications. It defines an interface and provides two implementations. The package stores notifications using either channels or slices, depending on your requirements. Let's take a closer look at the storage package:

var ErrEmpty = errors.New("no notifications found")

type Storage interface {
   Push(ctx context.Context, clientID int, notification entity.Notification) error
   Count(ctx context.Context, clientID int) (int, error)
   Pop(ctx context.Context, clientID int) (entity.Notification, error)
   PopAll(ctx context.Context, clientID int) ([]entity.Notification, error)
}

In this package, Push adds a new notification, Count returns the number of notifications for a client, Pop retrieves a single notification, and PopAll retrieves all notifications for a client.

Entity Package

The entity package defines the structure of notifications. It includes the Notification interface and some example implementations:

type Notification interface {
   IsNotification()
}

type BaseNotification struct {
   CreatedAt time.Time `json:"createdAt"`
}

func (BaseNotification) IsNotification() {}

type UnreadWorkRequest struct {
   BaseNotification
   WorkID int    `json:"workID"`
   Title  string `json:"title"`
}

type UnreadMessagesNotification struct {
   BaseNotification
   Count int `json:"count"`
}

Here, we define the Notification interface and provide a few notification types, such as UnreadWorkRequest and UnreadMessagesNotification. You can add more notification types based on your application's needs.

Storage Implementations

The storage package offers two implementations: one using channels and the other using slices.

Using Channels:

type memoryWithChannel struct {
   storage *sync.Map
   size    int
}

func NewMemoryWithChannel(size int) Storage {
   return &memoryWithChannel{
      storage: new(sync.Map),
      size:    size,
   }
}

// Functions for Push, Count, Pop, and PopAll using channels...

Using Slices:

type userStorage struct {
   mu            *sync.Mutex
   notifications []entity.Notification
}

type memoryWithList struct {
   size    int
   storage *sync.Map
}

func NewMemoryWithList(size int) Storage {
   return &memoryWithList{
      size:    size,
      storage: new(sync.Map),
   }
}

// Functions for Push, Count, Pop, and PopAll using slices...

Both implementations support the same functions, but the underlying data structures differ. When dealing with concurrency, it's essential to consider race conditions and thread safety.

Tips for Working with Concurrency:

  • Channels are thread-safe; you can read and write to them simultaneously in multiple threads.
  • Default maps in Go are not thread-safe. To manage concurrent access, you can use the sync.Map, which is thread-safe.
  • The contents of a sync.Map are not thread-safe. You should use a mutex for each slice.
  • The len function is thread-safe.
  • Channels have a fixed size and allocate memory upfront, unlike slices and maps, which can dynamically resize.

Handling Race Conditions:

It's crucial to handle race conditions when working with concurrency. Here's an example of a potential race condition:

func (m *memoryWithChannel) PopAll(ctx context.Context, clientID int) ([]entity.Notification, error) {
   c := m.get(clientID)
   l := len(c)
   items := make([]entity.Notification, l)
   for i := 0; i < l; i++ {
      items[i] = <-c
   }
   return items, nil
}

In this example, two simultaneous requests could both call len(c) at the same time, resulting in both requests attempting to retrieve 100 items from the channel. This can lead to a deadlock situation. The slice-based implementation doesn't have this issue.

Testing and Benchmarking:

To ensure your code performs well and doesn't lead to memory leaks, you can write tests and benchmarks. Here's an example of testing and benchmarking:

func testNewMemory(m Storage, t *testing.T) {
   // Test code...
}

func benchmarkMemory_PushAverage(m Storage, b *testing.B) {
   // Benchmark code...
}

func benchmarkMemory_PushNewItem(m Storage, b *testing.B) {
   // Benchmark code...
}

You can use benchmarks to measure performance and memory usage for different scenarios.

Signal Package

The signal package handles the signaling of new notifications to customers. It utilizes channels for signaling. Here's the code for the signal package:

var (
   ErrEmpty = errors.New("no topic found")
)

type Signal interface {
   Subscribe(id string) (<-chan struct{}, func(), error)
   Publish(id string) error
}

The Subscribe function returns a read-only channel and a cancel function. Customers use this channel to receive notifications, and the cancel function is used to clean up resources. The Publish function signals notifications to subscribed customers.

Signal Package (Continued)

The signal package continues:

type topic struct {
   listeners []chan<- struct{}
   mu        *sync.Mutex
}

type signal struct {
   listeners *sync.Map
   topicSize int
}

func NewSignal() Signal {
   return &signal{
      listeners: new(sync.Map),
   }
}

// Subscribe and Publish functions for signaling...

Crisp Package

The Crisp package serves as the core of the notification system. It uses the storage and signal packages to provide functionality for users to listen for their notifications and push new notifications. Here's the code for the Crisp package:

type Crisp struct {
   Storage storage.Storage
   Signal  signal.Signal

   defaultTimeout time.Duration
}

func NewCrisp(str storage.Storage, sig signal.Signal) *Crisp {
   return &Crisp{
      Storage: str,
      Signal:  sig,
      defaultTimeout: 2 * time.Minute,
   }
}

// GetNotifications and Notify functions...

The GetNotifications function allows users to retrieve their notifications. It handles long polling to provide notifications in real-time or when the user reconnects. The Notify function allows clients to push new notifications into the system.

HTTP Server

The HTTP server provides a way for clients to communicate with the Crisp package. Here's the code for the HTTP server:

func (s *Server) listen(c echo.Context) error {
   // Listen code...
}

// NotifyRequest struct and notify function...

The listen function serves notifications to clients, using long polling when necessary. The notify function allows clients to push new notifications into the system.

With this structure in place, you can customize the communication layer to use various methods, such as HTTP, terminals, or others, as needed. Separating communication concerns from other parts of the application provides flexibility and scalability.

This concludes the code for the Crisp notification system. You can adapt and expand this code to build a robust application notification system.


How SuprSend Notification Infrastructure Can Give You A Powerful Go-Based Notification System Without Ever Needing to Code?

SuprSend can help you abstract your developmental layer without compromising quality and code. Our team is led by two experienced co-founders who have a combined experience of more than 25 years in building notification stacks for different early/mid-to-large-sized companies. We've been through the ups and downs, the sleepless nights, and the moments of triumph that come with creating a dependable notification infrastructure.

This is our amazing team, and we're here to make your journey unforgettable :)

Image description

Now let's see how SuprSend can benefit you:

  1. Multi-Channel Support:

    • Add multiple communication channels (Email, SMS, Push, WhatsApp, Chat, App Inbox) with ease.
    • Seamless integration with various providers.
    • Flexible channel routing and management.
  2. Visual Template Editors:

    • Powerful, user-friendly template editors for all channels.
    • Centralized template management.
    • Versioning support for templates, enabling rapid changes without code modification.
  3. Intelligent Workflows:

    • Efficient notification delivery through single triggers.
    • Configurable fallbacks, retries, and smart routing between channels.
    • Handle various notification types (Transactional, Crons, Delays, Broadcast) effortlessly.
  4. Enhanced User Experience:

    • Preference management for user control.
    • Multi-lingual content delivery.
    • Smart channel routing and batching to avoid message bombardment.
    • Frequency caps and duplicate prevention.
  5. Comprehensive Analytics and Logs:

    • Real-time monitoring and logs for all channels.
    • Cross-channel analytics for message performance evaluation.
    • Receive real-time alerts for proactive troubleshooting.
  6. Developer-Friendly:

    • Simplified API for triggering notifications on all channels.
    • SDKs available in major programming languages.
    • Comprehensive documentation for ease of integration.
  7. App Inbox:

  8. Bifrost Integration:

    • Run notifications natively on a data warehouse for enhanced data management.
  9. User-Centric Preferences:

    • Allow users to set their notification preferences and opt-out if desired.
  10. Scalable and Time-Saving:

    • Quickly deploy notifications within hours, saving development time.
    • Minimal effort is required to set up notifications. Get started in under 5 minutes.
  11. 24*7 Customer Support:

    • Our team is distributed around various time zones, ensuring someone is always up to cater to customer queries.
    • We also received the 'Best Customer Support' badge from G2 for our unwavering dedication.

Still not convinced?

Let's talk, and we may be able to give you some super cool notification insights. And no commitments, we promise!

You can find Gaurav, CTO & cofounder, SuprSend here: GV

You can find Nikita, cofounder, SuprSend here: Nikita

To directly book a demo, go here: Book Demo


Similar to this knowledge bomb, I personally run a developer-led community on Slack. Where we discuss these kinds of implementations, integrations, some truth bombs, weird chats, virtual meets, and everything that will help a developer remain sane ;) Afterall, too much knowledge can be dangerous too.

I'm inviting you to join our free community, take part in discussions, and share your freaking experience & expertise. You can fill out this form, and a Slack invite will ring your email in a few days. We have amazing folks from some of the great companies (Atlassian, Scaler, Cisco, IBM and more), and you wouldn't wanna miss interacting with them. Invite Form

And I would be highly obliged if you can share that form with your dev friends, who are givers.