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 toMessenger
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!
Member discussion