Working on a successful and growing codebase, more often than not, requires changing and extending existing behavior. Code attracts code, and changes tend to cluster around existing features. This can be easy at first, since we can probably just extend the existing pattern, but it soon becomes painful if our code is not prepared for change.

Let’s look at a concrete example. Imagine we are working on an application that sends notifications to users via an API in response to some event. When there is only one API to interact with, the following class meets your needs:

class UserNotifier
  attr_reader :user

  def initialize(user)
    @user = user
  end

  def notify(message)
    # API code to send a message via email goes here
    # ...
  end
end

If our application is successful, soon we’ll be asked to support multiple channels of communication. At this point, extending the code is easy. We can just modify our class to support multiple channels:

class UserNotifier
  attr_reader :user

  def initialize(user)
    @user = user
  end

  def notify(message)
    channel = user.channel
    case channel.to_sym
    when :email
      # API code to send a message via email goes here
      # ...
    when :sms
      # API code to send a message via sms goes here
      # ...
    when :twitter
      # API code to send a message via twitter goes here
      # ...
    end
  end
end

These changes satisfy the new requirements. But, our class has taken on new responsibilities. It used to be in charge of one thing only: notifying users via an API. Now, it is also in charge of building the correct API object for the given channel.

In order to build these objects, the class knows what each one of them requires. Adding support for additional channels will increase complexity and give the class more reasons to change. This creates a vicious cycle: the more reasons to change the class has, the more code will get added to it, giving it even more reasons to change and increasing its complexity even more. The code becomes harder and harder to reason about, and we are touching it more and more often, making it difficult to work with and prone to regressions.

We can improve our design by applying SOLID object-oriented principles and design patterns. First, let’s extract the API specifics into small objects, each with a single responsibility:

class EmailMessenger
  def initialize(address)
    @address = address
  end

  def deliver(message)
    # API code to send a message via email goes here
    # ...
  end
end

class SmsMessenger
  def initialize(address)
    @address = address
  end

  def deliver(message)
    # API code to send a message via sms goes here
    # ...
  end
end

class TwitterMessenger
  def initialize(address)
    @address = address
  end

  def deliver(message)
    # API code to send a message via twitter goes here
    # ...
  end
end

Each Messenger object is in charge of interfacing with one API. Now we can change UserNotifier to use these objects:

class UserNotifier
  attr_reader :user

  def initialize(user)
    @user = user
  end

  def notify(message)
    address = user.address
    channel = user.channel.to_sym
    case channel
    when :email
      EmailMessenger.new(address).deliver(message)
    when :sms
      SmsMessenger.new(address).deliver(message)
    when :twitter
      TwitterMessenger.new(address).deliver(message)
    end
  end
end

Extracting Messenger objects decouples the UserNotifier from the specifics of the APIs.

UserNotifier no longer knows how to build API objects. It has fewer reasons to change than before. Still, adding new messaging channels results in changes to this class.

A pattern that’s very helpful when dealing with this type of structure is the Gang-of-Four factory method pattern. It looks like this:

class UserNotifier
  attr_reader :user

  def initialize(user)
    @user = user
  end

  def notify(message)
    address = user.address
    channel = user.channel
    messenger = build_messenger(channel, address)
    messenger.deliver(message)
  end

  def build_messenger(channel, address)
    case channel.to_sym
    when :email
      EmailMessenger.new(address)
    when :sms
      SmsMessenger.new(address)
    when :twitter
      TwitterMessenger.new(address)
    end
  end
end

Extracting the factory method makes it clear UserNotifier has two responsibilities:

  • Mapping channel types to Messenger objects.
  • Asking the Messenger to deliver a message.

Since we are trying to minimize the reasons for UserNotifier to change, let's apply the Single Responsibility Principle and extract the factory method into an object:

class MessengerFactory
  MESSENGER_CLASS = {
    email: EmailMessenger,
    sms: SmsMessenger,
    twitter: TwitterMessenger
  }.freeze

  def self.build(channel, *args)
    MESSENGER_CLASS[channel.to_sym]&.new(*args)
  end
end

Then, UserNotifier can delegate to MessengerFactory to build the correct Messenger object:

class UserNotifier
  attr_reader :user

  def initialize(user)
    @user = user
  end

  def notify(message)
    address = user.address
    channel = user.channel
    MessengerFactory.build(channel, address)&.deliver(message)
  end
end

The UserNotifier no longer knows how to build a Messenger object or what channel they correspond to. It only knows MessengerFactory will return an object that responds to deliver. This is an example of the Dependency Inversion Principle, where we rely on abstractions, not concretions.

The main benefit of extracting MessengerFactory is that UserNotifier no longer needs to change when adding new channels: Adding support for WhatsApp would require a new entry in MessengerFactory::MESSENGER_CLASS and a WhatsAppMessenger class.

All Messenger classes have the same initialization requirement. Adding a BaseMessenger class removes this duplication, simplifying the addition of new Messenger types:

class BaseMessenger
  attr_reader :address

  def initialize(address)
    @address = address
  end
end

Let’s take a look at what we’ve done so far.

Our UserNotifier class is in better shape now. However, all we’ve done is move the reason to change from UserNotifier to MessengerFactory. We can do better and completely remove the need for these classes to change when adding new channels.

A common approach in Ruby is to introduce a convention and use metaprogramming to build factory objects:

class MessengerFactory
  def self.build(channel, *args)
    Object.get_const("#{channel.capitalize}Messenger").new(*args)
  end
end

This approach works for our current requirements, but it has a few pitfalls:

  • It prevents a global search from finding all references to a class.
  • New keys or classes might not fit nicely with our convention: whats_app -> WhatsAppMessenger.
  • Changing a class name requires us to change all references to its corresponding factory key.

There is a different approach: Factory self-registration. Let’s generalize MessengerFactory so that it no longer references Messenger classes directly:

class MessengerFactory
  def self.registry
    @registry ||= {}
  end

  def self.register(key, klass)
    @registry[key&.to_sym] = klass
  end

  def self.build(key, *args)
    @registry[key&.to_sym].new(*args)
  end
end

The main change to the MessengerFactory is that it now allows classes to be registered with it. It no longer cares about what type of object it’s building. If it has a corresponding key in its registry, MessengerFactory will build it.

Now, we want our Messenger classes to register themselves on the factory. An elegant way to give our Messenger classes this ability is via a module:

module MessengerFactoryRegistration
  def self.included(base)
    base.extend ClassMethods
  end

  module ClassMethods
    def corresponds_to(key)
      MessengerFactory.register(key, self)
    end
  end
end

We can include MessengerFactoryRegistration in the BaseMessenger and configure a key on each Messenger class:

class BaseMessenger
  include MessengerFactoryRegistration

  attr_reader :address

  def initialize(address)
    @address = address
  end
end

class EmailMessenger < BaseMessenger
  corresponds_to :email
  # ...
end

class SmsMessenger < BaseMessenger
  corresponds_to :sms
  # ...
end

class TwitterMessenger < BaseMessenger
  corresponds_to :twitter
  # ...
end

Calling the corresponds_to method registers the class with the factory, under the provided key. The correspondence between a class and its key is explicitly stated in the class definition.

Our MessengerFactory is now open/closed: we can extend its functionality without having to modify it. Adding support for WhatsApp requires only a new class:

class WhatsAppMessenger < BaseMessenger
  corresponds_to :whats_app

  def deliver(message)
    # API code to send a message via email goes here
    # ...
  end
end

This approach provides the following benefits:

  • It does not require a convention.
  • Adding a new Messenger class does not require other classes to change.
  • Class names are decoupled from their factory keys. Renaming either one does not require the other to change.

That’s it! We started by adding complexity to a simple class, giving it multiple responsibilities and multiple reasons to change. Then, we applied the Single Responsibility Principle, the Dependency Inversion Principle, the Factory Pattern, and the Open/Closed Principle to produce a design that’s resilient to changes and easily extensible. And, each of these classes would be a joy to test as well!