Maintainable Code and the Open-Closed Principle
September 18, 2018
In part 1 of the SOLID series, we learned about how to write more flexible code with the Single Responsibility Principle (SRP). By isolating pieces of functionality in individual classes/modules the SRP helps us guard against unnecessarily coupling responsibilities. If the implementation of one responsibility changes, SRP-adherent design prevents the change from affecting other responsibilities. However, decoupling responsibilities does not necessarily mean a complete decoupling of classes/modules, functions, objects, etc. In most object-oriented code, different objects must deal with one another in some fashion. What then happens when a particular object needs to be changed? As with responsibility changes, this poses a challenge for the maintenance of downstream objects that could inadvertently be affected by the change. One way to reduce the impact of this challenge is to adhere to the second of the SOLID principles: the Open-Closed Principle (OCP).
In this post, we’re going to explore the OCP and how to put it into practice. In a slight departure from many discussions on the OCP, we’re going to conduct our exploration in JavaScript—a language not often associated with classical OCP tools such as interfaces. However, JavaScript code is as deserving of SOLID adherence as any other code, so let’s give it a shot!
A Quick Refresher on SOLID
SOLID is an acronym for a set of five software development principles, which if followed, are intended to help developers create flexible and clean code. The five principles are:
- The Single Responsibility Principle—Classes should have a single responsibility and thus only a single reason to change.
- The Open/Closed Principle—Classes and other entities should be open for extension but closed for modification.
- The Liskov Substitution Principle—Objects should be replaceable by their subtypes.
- The Interface Segregation Principle—Interfaces should be client specific rather than general.
- The Dependency Inversion Principle—Depend on abstractions rather than concretions.
The Open Closed Principle
Robert C. Martin, creator and chief evangelist of SOLID, credits Bertrand Meyer as the originator of the OCP. In his 1988 book Object Oriented Software Construction, Meyer describes the need to develop flexible systems that can adapt to change without breaking. To do this, Meyer advocates the design of systems where entities (classes, modules, functions, etc) are “open for extension, but closed for modification”. In his development of the SOLID principles, Martin runs with this idea, describing it as a “straightforward” attack against the threat of “fragile, rigid, unpredictable and un-reusable” code [1]. For his part, Martin breaks down the OCP into its two constituent parts, defining code that is “open for extension” as code to which you can add new behavior, and code that is “closed for modification” as code that is “inviolate” in that it’s design should never be changed once implemented. In other words, the OCP says that you can always add new code to an object, but should never change the design of old code.
The chief benefit of the OCP is maintainability. If you adhere to the OCP you can greatly decrease future maintenance costs. The opposite applies as well—when you don’t adhere to the OCP, future maintenance costs will be greater. Consider how the coupling of two entities affects their respective maintainability. The more a given entity knows about how another one is implemented, the more we can say that they are coupled. Therefore, if one of the two entities is changed, then the other must be changed too. Here is a simple example:
function announce(collection) {
console.log(collection.description);
collection.items.forEach(function(element) {
console.log(element);
});
}
var favoriteCities = {
items: ["Copenhagen", "Kampala", "Montevideo"],
description: "My favorite cities around the world:",
};
announce(favoriteCities);
// Logs:
// "My favorite cities around the world:"
// "Copenhagen"
// "Kampala"
// "Montevideo"
In this snippet we have a simple function called announce
that takes an object as an argument and uses that object’s items and description properties to log a message to the console. When we call this function and pass it the favoriteCities
object we get the expected output. But what if we decide that we don’t want the favoriteCities
object to store its items
in an array and decide it’s better to store them in an object?
function announce(collection) {
console.log(collection.description);
collection.items.forEach(function(element) {
console.log(element);
});
}
var favoriteCities = {
items: {
"Denmark": "Copenhagen",
"Uganda": "Kampala",
"Uraguay": "Montevideo"
},
description: "My favorite cities around the world:",
};
announce(favoriteCities);
// TypeError: collection.items.forEach is not a function
By changing our favoriteCities.items
implementation from an array to an object we effectively broke our announce function. The reason is that the announce
function knows too much about how favoriteCities
was implemented and expects it to have an items
property that is an array. Fixing this would be relatively trivial (perhaps we could add a conditional to the announce function to check first whether the collection.items
property is an array or an object), but at what long-term cost? What if we didn’t make this change until much later in development and we had lots of functions that used collection.items
? We would then have to add conditionals to every place that referenced items
.
A better solution is to use polymorphism and to let each collection
object decide for itself how its items
should be iterated over and logged. In this pattern, the announce
function doesn’t care whether the collections it works with use arrays, objects, or some other data structure to hold their items
. Here is one approach:
function announce(collection) {
console.log(collection.description);
collection.logItems();
}
var favoriteCities = {
items: {
"Denmark": "Copenhagen",
"Uganda": "Kampala",
"Uraguay": "Montevideo"
},
description: "My favorite cities around the world:",
logItems: function() {
Object.keys(this.items).forEach(function(key) {
console.log(this.items[key]);
}, this);
},
};
announce(favoriteCities);
// Logs:
// "My favorite cities around the world:"
// "Copenhagen"
// "Kampala"
// "Montevideo"
In this final snippet, we provide favoriteCities
with a logItems
method that implements how to log its items. As far as announce
is concerned, it can deal with any collection object so long as it has a description
property and a logItems
method. This is the OCP in action—the announce
function is extensible because it can handle any collection that guarantees these two properties but it is also closed to modification because we don’t have to change the source code in announce to change its available behaviors.
Abstractions as Extensions
In a 2014 blog article, Martin discusses the apparent paradox in writing entities that are simultaneously open for extension and yet closed to modification [2]. How can something be both open and closed at once? Martin uses the example of plugin architecture to describe how new features can be added to software without modifying the original source code. Plugins are useful at the system level, but what about at the entity level when objects are interacting with one another? In this case, the key is abstraction. We had a taste of this in the simple examples above when we abstracted out the logItems
functionality of our collection
objects. Let’s see if we can do the same with a slightly more complex program.
// Monster Types and Manager
var MonsterManager = {
init: function(monsters, locations) {
this.monsters = monsters;
this.locations = locations;
},
getRandomLocation: function() {
function getRandomInt(max) {
return Math.floor(Math.random() * Math.floor(max));
}
return this.locations[getRandomInt(this.locations.length)];
},
rampageAll: function() {
this.monsters.forEach(function(monster) {
var location = this.getRandomLocation();
if (Object.getPrototypeOf(monster) == Kaiju) {
console.log(
"The " + monster.type + " " + monster.name +
" is rampaging through " + location + "!"
);
} else if (Object.getPrototypeOf(monster) == GreatOldOne) {
console.log(
"The " + monster.type + " " + monster.name +
" has awaken from its slumber in " + location + "!"
);
}
}, this);
}
};
var Kaiju = {
init: function(name) {
this.name = name;
this.type = "Kaiju";
return this;
}
};
var GreatOldOne = {
init: function(name) {
this.name = name;
this.type = "Great Old One";
return this;
}
};
// Rampage!
var monsters = [];
var locations = ["Athens", "Budapest", "New York", "Santiago", "Tokyo"];
var rodan = Object.create(Kaiju).init("Rodan");
monsters.push(rodan);
var gzxtyos = Object.create(GreatOldOne).init("Gzxtyos");
monsters.push(gzxtyos);
var myMonsterManager = Object.create(MonsterManager);
myMonsterManager.init(monsters, locations);
myMonsterManager.rampageAll();
// Logs: (with variable city names)
// The Kaiju Rodan is rampaging through Santiago!
// The Great Old One Gzxtyos has awaken from its slumber in Athens!
In this snippet we use the OLOO pattern to define a MonsterManager
prototype object and two types of monster prototypes, Kaiju
and GreatOldOne
. After initializing some monsters and an array of locations
, we then initialize a new MonsterManager
called myMonsterManager
and call its rampageAll
method, unleashing our monsters on those unlucky cities the randomLocation
method happens to choose (sorry!) Can you spot any problems in this code related to OCP adherence?
Take a look at the rampageAll
method—right now it iterates over each monster and checks whether they are of type Kaiju
or GreatOldOne
and then logs an appropriate message. What happens when this monster-filled world surfaces some new and terrible type of monster? In order for the program to work we would have to add another branch of conditional logic to the rampageAll
method. In other words, we would have to modify the source code and therefore break the OCP. Doing so would not be a big deal with just one more monster type, but what about 10 new types? Or 20? Or 1,000? (Apparently this poor world is filled with monsters!) In order to extend the behavior of our MonsterManager
(that is, let it deal with more types of monsters) we are going to have to think about how we deal with individual monster types.
Ultimately, the MonsterManager
probably shouldn’t care about how each different monster rampages, so long as it has the ability to rampage
in some fashion. Implementing our program this way would allow us to abstract away the rampage
functionality to each individual monster. In other words, we can extend the functionality of the rampageAll
method without changing the source code of MonsterManager
. This use of abstraction is often described as a sort of contract—the objects being used promise to implement some piece of functionality and the object using them promises not to care how they do it. In this case, each monster promises to have a rampage
function and MonsterManager
promises to let them handle the details.
As a means of implementing this pattern, languages like C# and Java have an abstraction called an interface. An interface can be used to create the kind of contracts described above. Unfortunately, JavaScript does not have interfaces; however, we can roughly approximate some of the behavior of an interface by using prototypal delegation and a custom validation function. Let’s try to do that with our monster program.
// Interface Approximation Utilities
function ImplementationError(message) {
this.name = "ImplementationError";
this.message = message;
}
ImplementationError.prototype = new Error();
function createWithInterfaceValidation(prototypeObject, interfaceObject) {
Object.keys(interfaceObject).forEach(function(key) {
if (prototypeObject[key] === null || typeof prototypeObject[key] !== "function") {
throw new ImplementationError(
"Required method " + key + " has not been implemented."
);
}
});
return Object.create(prototypeObject);
}
// Monster Types and Manager
var MonsterManager = {
init: function(monsters, locations) {
this.monsters = monsters;
this.locations = locations;
},
getRandomLocation: function() {
function getRandomInt(max) {
return Math.floor(Math.random() * Math.floor(max));
}
return this.locations[getRandomInt(this.locations.length)];
},
rampageAll: function() {
this.monsters.forEach(function(monster) {
var location = this.getRandomLocation();
monster.rampage(location);
}, this);
}
};
var MonsterInterface = {
init: null,
rampage: null,
};
var Kaiju = Object.create(MonsterInterface);
Kaiju.init = function(name) {
this.name = name;
this.type = "Kaiju";
return this;
};
Kaiju.rampage = function(location) {
console.log(
"The " + this.type + " " + this.name +
" is rampaging through " + location + "!"
);
};
var GreatOldOne = Object.create(MonsterInterface);
GreatOldOne.init = function(name) {
this.name = name;
this.type = "Great Old One";
return this;
};
GreatOldOne.rampage = function(location) {
console.log(
"The " + this.type + " " + this.name +
" has awaken from its slumber in " + location + "!"
);
};
var MythicalMonster = Object.create(MonsterInterface);
MythicalMonster.init = function(name) {
this.name = name;
this.type = "Mythical Monster";
return this;
};
MythicalMonster.rampage = function(location) {
console.log(
"The " + this.type + " " + this.name +
" has been sighted in " + location + "!"
);
};
// Rampage!
var monsters = [];
var locations = ["Athens", "Budapest", "New York", "Santiago", "Tokyo"];
var rodan = createWithInterfaceValidation(Kaiju, MonsterInterface);
rodan.init("Rodan");
monsters.push(rodan);
var gzxtyos = createWithInterfaceValidation(GreatOldOne, MonsterInterface);
gzxtyos.init("Gzxtyos");
monsters.push(gzxtyos);
var cerberus = createWithInterfaceValidation(MythicalMonster, MonsterInterface);
cerberus.init("Cerberus");
monsters.push(cerberus);
var myMonsterManager = Object.create(MonsterManager);
myMonsterManager.init(monsters, locations);
myMonsterManager.rampageAll();
// Logs: (with variable city names)
// The Kaiju Rodan is rampaging through Tokyo!
// The Great Old One Gzxtyos has awaken from its slumber in Athens!
// The Mythical Monster Cerberus has been sighted in New York!
In this snippet, we have a custom ImplementationError
as well as a function called createWithInterfaceValidation
, which takes prototypeObject
and interfaceObject
parameters. This function iterates over the interfaceObject
parameter to identify which properties should be implemented on the prototypeObject
and throws an ImplementationError
if they are not implemented. If no errors are thrown then the function returns a new object linked to the passed in prototypeObject
. By using this function we can replicate some (though not all) of the functionality of classical interfaces.
In the rest of the snippet we have new version of our MonsterManager
and a few monster types. The difference however is that the rampageAll
function no longer has any conditional logic. Rather, it assumes that each monster has implemented a rampage
function. When creating our monster types we guarantee exactly this by using a MonsterInterface
object as the prototype for each monster type and then using the createWithInterfaceValidation
function whenever we instantiate a new monster. In this fashion, we can be sure that every monster has a valid rampage
method, otherwise an ImplementationError
would be thrown.
This snippet still leaves a lot of room for improvement (DRYer code, type checking, signature checking, custom error messages, additional OCP-adherence opportunities, etc.); however, we can already see a number of improvements over the first version. Most importantly, our MonsterManager
is extensible in that we can add new behavior but it is also closed to modification in that we don’t need to change the source code when adding that new behavior. We can create as many monster types as we like, so long as they all have a rampage
method. This goes to the core of what the OCP is all about.
TL;DR
The second of the SOLID principles of software development is the Open-Closed Principle (OCP), which says that software entities (objects, classes, modules, etc.) should be “open for extension” but “closed to modification”. In this context, extension means adding new behavior and modification means altering existing source code. The OCP is a useful principle for keeping your code maintainable because it ensures that old working code is not changed (causing downstream breakage) while simultaneously allowing for the addition of new behavior. One method for adhering to the OCP is relying on abstractions rather than concretions. When one object interacts with another, it should do so through an abstraction, allowing its partner object to worry about specific implementation. The classic way to do this is with interfaces or other abstractions; however, some languages like JavaScript do not provide native interface abstractions. In this case, it is still possible to follow the OCP either through convention or through custom validation methods.
References
- Book: Clean Code; Martin, Robert C.
- Paper: The Open-Closed Principle; Martin, Robert C.; 1996
- Article: The Open Closed Principle; Martin, Robert C.; 2014
- Resource: clean-code-javascript; Mcdermott, Ryan
- Wikipedia: Open-closed principle
That’s all for our discussion of the OCP. Stay tuned for articles on the remaining three SOLID principles—starting with part 3 on the Liskov Substitution Principle. And if you want to go back to the beginning of the series, you can find part 1 here. If you have any comments or questions, leave them below—I would love to hear what you think.
Note: This article was originally published on Medium.