Open/Closed and the Single Responsibility Principle

single-responsibility
The SOLID principles of object-oriented design provide good guidelines for writing code that is easy to change, but for some of the principles, the motivation and value can be difficult to understand. Open/Closed is particularly vexing: software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification What does it mean to be extended but not modified? Does this principle lead inherently to deep, convoluted class hierarchies? There has been some discussion and criticism of SOLID and the Open/Closed principle recently, so we thought we’d share some of the Rescale development team’s experiences coming to an understanding of Open/Closed and using it to write classes whose behavior is easy to change. We view Open/Closed as a complement to the Single Responsibility Principle since it encourages developers to write classes with focused responsibilities whose behavior is easy to change with minimal code modifications.

An explanation

First off, let’s explore some of our initial questions. What does it mean for code to be extended? What does it mean for it to be modified? Code is modified when we change the code itself–by adding conditionals to methods, pulling in extra arguments, switching behavior based on properties of arguments, and so on. This is often developers’ first thought on how to make existing code accomplish a new task–just add another conditional. Code is extended when we change its behavior without modifying the code itself– by injecting different collaborators. For our code to even be amenable to such extension, we have to be following many of the other SOLID principles–injecting different objects for each aspect of the task we wish to accomplish. That in turn requires that each aspect of the task be accomplished by a different object, which is the heart of the Single Responsibility Principle–objects should do only one thing. Open/Closed encourages developers to write code that distributes responsibilities and provides the benefits of making behavior easy to change without growing long methods and large classes.

An example

Let’s illustrate all this theory with an example. At Rescale, we recently refactored a bunch of code to use a REST API client. API clients are often written with different methods for each type of request callers wish to make, and that’s how ours started. We had an interface like:

public interface APIClient {
    Analysis getAnalysis(long jobId);
    HardwareSummary getHardwareSummary(long jobId);
    ...
}

Each of the method implementations looked like:

public Analysis getAnalysis(long jobId) {
    String path = “/api/jobs/” + jobId + “/analysis/”;
    String response = makeHttpRequest(new URL(this.baseApiUrl, path));
    return parseJson(response, Analysis.class);
}

This was a pain to work with. Every time we wanted to get a new piece of data from the API we had to add a new, redundant method. This client wasn’t open for extension, and in order to change its behavior we had to modify the class. That stemmed from a muddying of responsibilities. Even though the method above looks simple, it actually has several different responsibilities and corresponding pieces of knowledge. It knows how to format paths for each request type. It knows how to make http requests to the API and knows how to parse json for each request. We decided to refactor this to give our API client just one responsibility–making requests. Instead, we would extend it by passing in Request objects that took over the other responsibilities. Now we have an API client interface with just one method:

public interface APIClient {
    public  T get(Request request);
}

Since each type of request is relatively static, we have static factory methods to create request objects.

public class Request {
    public static void Request forAnalysis(Job job) {
    ...
    }
}

And, thanks to Java generics, callers get back the correct type of object. We now have natural looking code like:

Analysis analysis = client.get(Request.forAnalysis(this.job));

And retrieving new information from the API is easy. We don’t have to modify the API client, we just extend it by passing in a different request.

Summary

There’s a lot of confusion about what Open/Closed implies, but it simply requires that objects have focused responsibilities and interact with other objects that are injected–that way developers are free to inject different collaborators to change behavior. Anyone who has worked in a large codebase has seen sections that were developed without Open/Closed in mind. This code is ostensibly simple and “just gets the job done”. The problem is that as the business desires inevitably change, the only way to change the job is to change the code. Methods accumulate conditionals and switch statements, classes accumulate fields, and the code becomes harder to understand at a glance. The poor design feeds on itself because the easiest way to accomplish a task is to simply add a few more lines to a method. If developers keep Open/Closed in mind, then they will be able to spot opportunities to create objects with extensible behavior and keep development costs from growing out of control.

Author

  • Adam McKenzie

    As CTO, Adam is responsible for managing the HPC and customer success teams. Adam began his career at Boeing, where he spent seven years working on the 787, managing structural and software engineering projects designing, analyzing, and optimizing the wing. Adam holds a B.S. in Mechanical Engineering cum laude from Oregon State University.

Similar Posts