Extension without Modification

Build objects that are easily extensible and resilient to change

Published in
4 min readMar 22, 2019

--

Building software often requires changing and extending existing behavior. This need presents itself in different ways. One example is the need to take different actions depending on some input. This can be a painful process if our code is not prepared for change.

Let’s look at a concrete example. Imagine we are working on an application in charge of sending notifications to users via an API. When there is only one API to interact with, the following class meets our needs:

If our application is successful, we’ll be required to support multiple channels of communication. No problem, we can modify our class to support multiple channels:

Our 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, our class knows what each one of them requires. Adding support for additional channels will increase the complexity and brittleness of this class. It has too many reasons to change.

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:

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

Extracting Messenger objects decouples theUserNotifier 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. Let’s apply it to our code and see what it reveals:

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:

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

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 you 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:

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:

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:

The main change to the MessengerFactory is that it now allows classes to be registered on 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:

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

Calling the corresponds_to method registers the class on 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:

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.

My team and I use this pattern. Adding new functionality to our application usually only requires adding a new class and its corresponding tests. In fact, we have gotten so much value out of this pattern that we decided to extract it into an open source gem: Industrialist.

Industrialist provides the factory and self-registration functionality. All you have to do is configure your base class:

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 and the factory pattern to produce a design that’s resilient to changes and easily extensible. I bet each of these classes would be a joy to test as well!

This is the way my team writes software. If you’d like to know more about Entelo or our Engineering Team, please visit SourceCode, our blog about engineering in the recruiting industry.

I’d like to thank Alan Ridlehoover for introducing me to this pattern and pairing with me on it. I’d also like to thank Vijay Ram for his clarifying questions and comments.

--

--