Buy Me a Coffee

[Golang] Exploring the Observer Design Pattern with Code Example

Observer pattern illustration depicting a central subject with multiple observers connected

Introduction

Today, we will dive into understanding the Observer Design Pattern with a Golang code example. This pattern is a cornerstone of software design, promoting flexibility and reuse in code by managing dependencies effectively.

What Is the Observer Pattern?

The Observer pattern defines a one-to-many dependency between objects, ensuring that when one object (the Subject) changes its state, all its dependents (Observers) are automatically notified. This pattern is pivotal in creating an efficient and decoupled architecture.

When to Use the Observer Pattern?

  1. When multiple objects (subscribers) need to react to state changes of another object (Subject).
  2. When you want to decouple the Subject from its Observers.
  3. When the number of Observers can change dynamically.
  4. When you want to avoid polling or tight coupling between components.

A Use Case for Backend Development

Consider a user activity tracking feature where user operations, like registration and payments, need to be logged. You also want notifications for these actions. Without a design pattern, the code can become entangled and hard to manage.

type EmailService struct {
	emails []string
}

func (e *EmailService) SendEmail(userName string, message string) error {
	return nil
}

type DBService struct {
	db *sql.DB
}

func (d *DBService) SaveUserActivity(userName string, activity string) error {
	return nil
}

type TopUpHandler struct {
	emailService *EmailService
	dbService    *DBService
}

func (h *TopUpHandler) HandleTopUp(userName string, amount float64) error {
	// ...
	// top up logic
	// ...

	if err := h.dbService.SaveUserActivity(userName, "topup"); err != nil {
		return err
	}

	if err := h.emailService.SendEmail(userName, "top up successful"); err != nil {
		return err
	}

	return nil
}

Problems With This Approach

  1. Adding new notification means manually updating every handler to include calls like h.slackService.SendMessage().
  2. Removing an existing notification requires removing code from each handler.
  3. High risk of inconsistency across handlers if updates are missed.

This tightly couples handlers with notifications, leading to complex redundant code.

Implementing Observer Pattern

With the Observer pattern, we first identify the Subject and Observers. Here, the Subject is ActivityService, and Observers include DBService and EmailService. The relevant state change is the top-up operation.

Interface Definitions

// Subject 
type Subject interface {
	RegisterObserver(observer ...Observer)
	DeregisterObserver(observer Observer)
	NotifyAll(msg string) error
}

// Observer
type Observer interface {
	Update(msg string) error
}

These are the minimal contracts for implementing an Observer pattern. Let’s look at concrete implementations.

Subject

type ActivityService struct {
	observers []Observer
	mux       sync.Mutex
}

func NewActivityService() *ActivityService {
	return &ActivityService{
		observers: make([]Observer, 0),
		mux:       sync.Mutex{},
	}
}

func (s *ActivityService) RegisterObserver(observer ...Observer) {
	s.mux.Lock()
	defer s.mux.Unlock()
	s.observers = append(s.observers, observer...)
}

func (s *ActivityService) DeregisterObserver(observer Observer) {
	s.mux.Lock()
	defer s.mux.Unlock()
	for i, o := range s.observers {
		if o == observer {
			s.observers = append(s.observers[:i], s.observers[i+1:]...)
			break
		}
	}
}

func (s *ActivityService) NotifyAll(msg string) error {
	s.mux.Lock()
	defer s.mux.Unlock()
	for _, observer := range s.observers {
		if err := observer.Update(msg); err != nil {
			return err
		}
	}
	return nil
}

Observers

type MailObserver struct {
	emails []string
}

func (m *MailObserver) Update(msg string) error {
	fmt.Printf("MailObserver: %s\n", msg)
	return nil
}

type LogObserver struct {
	db *sql.DB
}

func (l *LogObserver) Update(msg string) error {
	fmt.Printf("LogObserver: %s\n", msg)
	return nil
}

Putting It All Together

func main() {
	// init subject 
	activityService := NewActivityService()

	// init observer
	mailObserver := &MailObserver{}
	logObserver := &LogObserver{}

	// subscribe observer to subject 
	activityService.RegisterObserver(mailObserver, logObserver)
}

Reacting to State Changes

For a user top-up, you can notify all observers with a single call: activityService.NotifyAll("john just topped up $20").

Adapting to Change

To replace email notifications with Slack messages:

type SlackObserver struct {
	channelIDs []string
	token      string
}

func (s *SlackObserver) Update(msg string) error {
	fmt.Printf("SlackObserver: %s\n", msg)
	return nil
}

slackObserver := &SlackObserver{}
activityService.DeregisterObserver(mailObserver)
activityService.RegisterObserver(slackObserver)

This flexibility emphasizes the pattern’s power. And changes don’t require modifying the core logic of handlers.

You can find the complete code example here.

Practice

Feeling inspired? Try implementing a Crypto Price Watcher:

Concept

A Go program polls for Bitcoin/Ethereum prices every 2 seconds, notifying Observers (like Discord/Telegram bots) on price changes.

Components:

  • Subject: PriceFeed
  • Observers: DiscordNotifier, TelegramNotifier, EmailNotifier

Features:

  • Demonstrates real-time updates and observer dynamics.

Feel free to tweak the example with print statements for simulation. An example can be found in my GitHub repository.

The output of the example code is as follows:

Observer Pattern Output

Let me know if you are interested in reading more Golang design pattern posts like this.


Enjoyed this article? Support my work with a coffee ☕ on Ko-fi.
Buy Me a Coffee at ko-fi.com
DigitalOcean Referral Badge
Sign up to get $200, 60-day account credit !