An Introduction to the Open/Closed Principle
In last week’s newsletter I said that I’d be talking about polymorphism this week. I was wrong about that. Instead, we’re going to talk about the Open Closed Principle (OCP). We’re also not going to get to an Exercism problem this week. I’m want to really illustrate OCP* this week and next week we’ll use it on an Exercism problem.
Before getting into the code, I’m going to try to clearly state the OCP*. The way I’ve heard it goes something like:
Your code should be open to extension but closed to modification.
Another way of saying that is:
Adding new features should not require any changes to your existing features.
But what does that mean in practice? Let’s start with a simple example:
class Person
attr_accessor :email
def enroll
Newsletter.add(Person.email)
end
end
Does this code conform to OCP*? Just like Sandi Metz’s TRUE heuristic in last week’s post, we can’t judge by looking at static code. We have to judge in the context of adding new functionality. If we wanted to add a method to display a Person’s name then this code wouldn’t have to change, making it Open/Closed.
But if we add a new newsletter that people can enroll in, then this code is not Open/Closed. If we followed the principle and didn’t change any of our existing code, we’d have to add the new method like this:
class Person
attr_accessor :email
def enroll
Newsletter.add(email)
end
def enroll_vip
VIPNewsletter.add(email)
end
def enroll_all
enroll
enroll_vip
end
end
And that’s obviously not a road we want to go down. How do we improve the design of this code so that is Open/Closed to the functionality we want to add? The above implementation is very helpful in figuring our our next steps. We have two methods: enroll
and enroll_vip
that are nearly identical. If we can extract out the difference between those two methods, then we can implement a single solution that is Open/Closed to adding new newsletters.
Once we see that, the implementation is easy:
class Person
attr_accessor :email
def enroll(newsletter = Newsletter)
newsletter.add(email)
end
end
By giving the newsletter parameter a default value of Newsletter
we don’t have to change any existing clients of this code. But as we add new code, we can pass in the VIPNewsletter
or SecretNewsletter
or whatever we want.
The steps we just took are a simple, repeatable mechanism for improving the design of your code and following the Open/Closed principle. Just follow the steps:
- Find a new feature to implement
- Implement it without changing any existing code
- Extract out the difference between your new code and the existing code
- Repeat as needed
This procedure comes with another benefit: your code never breaks. Let’s go through the above steps in finer detail and show why that’s true.
Step 0: Initial Code
Here’s our starting code again:
class Person
attr_accessor :email
def enroll
Newsletter.add(email)
end
end
And let’s say there’s a test:
class PersonTest
it "adds the email to the newsletter" do
expect(Newsletter).to receieve(:add).with(@person.email)
end
end
We get the feature request, “We’re adding a new VIP Newsletter that people can enroll in.” We look at this code and determine that we can not implement that feature without changing the enroll
method. So we try creating a new method.
Step 1: Implement New Method
class Person
attr_accessor :email
def enroll
Newsletter.add(email)
end
def enroll_vip
VIPNewsletter.add(email)
end
end
And we follow the same testing approach:
class PersonTest
describe "enroll" do
it "adds the email to the newsletter" do
expect(Newsletter).to receieve(:add).with(@person.email)
end
end
describe "enroll_vip" do
it "adds the email to the vip newsletter" do
expect(VIPNewsletter).to receieve(:add).with(@person.email)
end
end
end
During that process our existing code and tests didn’t break because we never changed the enroll
method.
Step 2: Refactor
Now that the design problem that prevents enroll
from being Open/Closed is clear, we can refactor our code. We do so cautiously.
class Person
attr_accessor :email
def enroll
Newsletter.add(email)
end
def enroll(newsletter = Newsletter)
newsletter.add(email)
end
def enroll_vip
VIPNewsletter.add(email)
end
end
The refactoring we did here is Add Parameter (Refactoring, p. 275). It’s a pretty easy one, but if you look at the steps that Fowler suggests, they are very cautious:
- Look for places where the method is implemented, to be sure to get all of them.
- Declare a new method with the added parameter.
- Verify the code. (compile, tests, etc.)
- Change the old method to call the new one.
- Verify the code.
- Change all clients of the old method.
- Remove the old method.
- Verify the code.
In our example, we’ve overridden our old implementation with the new one. We can then run the tests and verify that things work. They do, so we can continue:
class Person
attr_accessor :email
def enroll(newsletter = Newsletter)
newsletter.add(email)
end
def enroll_vip
enroll(VIPNewsletter)
end
end
Verify again and everything still works. At this point we can finish up by tweaking our new test
class PersonTest
#...
describe "enroll_vip" do
it "adds the email to the vip newsletter" do
expect(VIPNewsletter).to receieve(:add).with(@person.email)
@person.enroll(VIPNewsletter)
end
end
end
Then delete the enroll_vip
method.
class Person
attr_accessor :email
def enroll(newsletter = Newsletter)
newsletter.add(email)
end
end
Now we can sneakily enroll people into any newsletter we want! And, while adding the feature our tests never broke and we never ended up in that awful state of a half-finished feature surrounded by a pile of tests that will pass again soon.
Obviously the above change was simple. With more complex changes you want to be more cautious. After you see the refactoring you need to do, remove the new feature. Then refactor the code until it is Open/Closed to the new feature you want to add.
Meeting the Open/Closed Principle depends entirely on the features you need to add. Our current code is Open/Closed to any email-based newsletter. But if a person wanted to enroll to a text message newsletter that was delivered to a phone number, then our existing code would not be Open/Closed to that change. There is no such thing as code that is always Open/Closed. If there was then the world would need far fewer programmers.
A small fraction of the world’s many programmers read my weekly newsletter. You can too! Sign-up is easy and free and you can always checkout previous newsletters. Comments/feedback/&c. welcome on twitter, GitHub or ian@ianwhitney.com