Refactoring to Observer Pattern

Sai Prasanth NG
Sai Prasanth NG
Partner January 04, 2019
#codingpractices

Scenario

Let’s take a scenario in which we are trying to automate our home. We want to play a welcome message as soon as we get home.

We decide to play a welcome message whenever our phone is connected to the modem at the house. So we create a class Modem and pass an instance of ‘Speaker’ class to it, The Modem object can use this instance to play the welcome message.

class Modem
  def initialize(speaker)
    @speaker = speaker
    @connected_devices = []
  end

  def connect_device(device)
    @connected_devices << device
    @speaker.play_welcome_message
  end
end

speaker = Speaker.new
modem = Modem.new(speaker)
phone = Phone.new
modem.connect_device(phone)
=> Welcome Home

One disadvantage of our code is that the Modem and Speaker class are tightly coupled. We cannot reuse the Modem class without passing a Speaker object to it. So let’s refactor our code to make our classes reusable and not tightly coupled with each other.

class Modem
  def add_listener(listener)
    (@listeners ||= []) << listener
  end

  def notify
    @listeners.each { |listener| listener.update }
  end

  def connect_device(device)
    (@connected_devices ||= []) << device
    notify
  end
end

class Speaker
  def update
    play_welcome_message
  end
end

phone = Phone.new
modem = Modem.new
speaker = Speaker.new

modem.add_listener(speaker)
modem.connect_device(phone)
=> Welcome Home

We have added an instance method called add_listeneres on Modem class, to register an object that is interested in listening for changes on it. Whenever there is a change in the Modem instance like our phone gets connected to the modem, we call the notify method on the Modem which in turn calls update method on each of the objects that have registered as listeners. The listeners should implement this update method to perform logic that needs to get triggered.

Concept

We have refactored our initial code to use Observer design pattern. In this scenario, the Modem is called the subject and the Speaker and Light are the observers. These observers register themselves as listeners on the Subject. When the Subject changes its state, it sends a notification to all the observers that have registered as listeners to it.

When to use

  • When an object depends on another object’s changes
  • When a change in one object requires changing other objects
  • When an object should be able to notify other objects without making assumptions about what these objects are.

Advantages

  • Subject only needs to know that observer implements Observer interface and nothing else.
  • There is no need to modify subjects to add new listeners.
  • We can reuse the subject & observer classes independent of each other

Implementation

Here are a few points to keep in mind while implementing observer pattern:

Pull/Push method

We generally tend to send information to the observers on what triggered the notification on the subject. We can do this in two ways the pull method or the push method.

Pull method

In the pull method, we send very less information to the observer. The observer has to deduce what changed in the subject without the help of the subject.

class Modem
  ...

  def notify
    @listeners.each { |listener| listener.update(self) }
  end

  def disconnect_device(device)
    @connected_devices.delete(device)
    notify
  end

  def any_phone_connected?
    ...
  end
end

class Speaker
  def update(modem)
    if modem.any_phone_connected?
      play_welcome_message
    else
      play_farewell_message
    end
  end
end

phone = Phone.new
modem = Modem.new
speaker = Speaker.new

modem.add_listener(speaker)
modem.connect_device(phone)
=> Welcome Home
modem.disconnect_device(phone)
=> Have a good day

Push method

The push method, the subject has to know what the observers need and send the required information along with the notification. This makes the observer less usable.

class Modem
  ...

  def notify(event_name)
    @listeners.each { |listener| listener.update(event_name) }
  end

  def connect_device(device)
    (@connected_devices ||= []) << device
    notify("#{device.class.to_s.downcase}_connected")
  end

  def disconnect_device(device)
    @connected_devices.delete(device)
    notify("#{device.class.to_s.downcase}_disconnected")
  end
end

class Speaker
  def update(event_name)
    case event_name
    when 'phone_connected'
      play_welcome_message
    when 'phone_disconnected'
      play_farewell_message
    end
  end
end

modem = Modem.new
speaker = Speaker.new

modem.add_listener(speaker)
modem.connect_device(phone)
=> Welcome Home
modem.disconnect_device(phone)
=> Have a good day

Improved push method

We can improve the `push method` by adding support for listeners to provide a list of events they are interested in at the time of registration. The subject notifies the observers only if the event they are interested in occurs.

class Modem
  def add_listener(listener, events)
    (@listeners ||= {})[listener] = events
  end

  def notify(event_name)
    @listeners.each do |listener, events|
      listener.update(event_name) if events.include?(event_name)
    end
  end

  def connect_device(device)
    ...
  end

  def disconnect_device(device)
    ...
  end
end

class Speaker
  def update(event_name)
    case event_name
    when 'phone_connected'
      play_welcome_message
    when 'phone_disconnected'
      play_farewell_message
    end
  end
end

modem = Modem.new
speaker = Speaker.new

modem.add_listener(speaker, ['phone_connected', 'phone_disconnected'])
modem.connect_device(phone)
=> Welcome Home
modem.disconnect_device(phone)
=> Have a good day

Listening to multiple subjects

If the observer is listening to more than one subject, then it makes sense to let the observer know which subject is sending the notification. We can do this by passing a reference of the subject itself as a parameter when notifying the observers.

class Modem
  # We modify our Speaker to play welcome message only when a phone is connected to the modem and the lights are turned on
  def add_listener(listener)
    (@listeners ||= []) << listener
  end

  def notify
    @listeners.each { |listener| listener.update(self) }
  end

  def connect_device(device)
    (@connected_devices ||= []) << device
    notify
  end
end

class Light
  def notify(event_name)
    @listeners.each do |listener| 
      listener.update(self)
    end
  end
 
  def turn_on
    @state = on
    notify
  end
end

class Speaker
  def update(subject)
    if subject.instance_of? Modem
      play_welcome_message
    elsif subject.instance_of? Light
      play_farewell_message
    end
  end
end

phone = Phone.new
modem = Modem.new
speaker = Speaker.new
light = Light.new

modem.add_listener(speaker)
light.add_listener(speaker)
modem.connect_device(phone)
=> Welcome Home
Modem.light_off
=> Have a good day

Using change manager

If an observer depends on multiple subject changes to perform an action, then it makes sense to have a change manager which acts as a mediator between all the subjects and handles the complex logic.

# We modify our Speaker to play welcome message only when a phone is connected to the modem and the lights are turned on

class Modem
  def add_listener(listener)
    (@listeners ||= []) << events
  end

  def notify(event_name)
    @listeners.each { |listener| listener.update(event_name) }
  end

  def connect_device(device)
    (@connected_devices ||= []) << device
    notify("#{device.class.underscore}_connected")
  end

  def disconnect_device(device)
    @connected_devices.delete(device)
    notify("#{device.class.underscore}_disconnected")
  end
end

class Light
  def turn_on
    @state = on
    notify('light_on')
  end
end

class Hub
  def add_device(device)
    case device.class
    when Light
      @light = device
    when Speaker
      @speaker = device
    when Modem
      @modem = device
    end
  end

  def update(event_name)
    case event_name
    when 'phone_connected'
      @phone_connected = true
    when 'phone_disconnected'
      @phone_connected = false
    when 'light_on'
      @light_on = true
    when 'light_off'
      @light_on = false
    end

    case
    when @phone_connected && @light_on
      @speaker.play_welcome_message
    else
      @speaker.play_farewell_message
    end
  end
end

class Speaker
end

phone = Phone.new
modem = Modem.new
speaker = Speaker.new
light = Light.new
hub = Hub.new
hub.add_device(modem)
hub.add_device(light)
hub.add_device(speaker)

modem.add_listener(hub)
light.add_listener(hub)

modem.connect_device(phone)
light.turn_on
=> Welcome Home
modem.disconnect_device(phone)
=> Have a good day

Here the Hub class acts as a mediator between the Modem, Light and Speaker. It encapsulates all the complex logic on when to perform actions on the Speaker based on the events.