Two controllers, type conformance and the Liskov Substitution Principle
An interesting object orientation related problem that Raph and I were looking at recently revolved around the design of two controllers in the application we’ve been working on.
The two controllers in question look roughly like this:
public class GenericController extends Controller {
private final SomeFactory someFactory;
public GenericController(SomeFactory someFactory);
this.someFactory = someFactory;
}
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
// do some stuff but never use 'request' or 'response'
}
}
public class MoreSpecificController extends GenericController {
private final SomeFactory someFactory;
public MoreSpecificController(SomeFactory someFactory);
super(someFactory);
}
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
...
// do some stuff which does use 'request'
String someValue = request.getParameter("someParameter");
}
}
We noticed from the way that we wrote tests for these two classes that we seem to be breaking the principle of type conformance as defined in Meilir Page Jones' 'Fundamentals of Object Oriented Design in UML' and more commonly referred to as the Liskov Substitution Principle.
The principle states:
If S is a true subtype of T, then S must conform to T. In other words, an object of type S can be provided in any context where an object of T is expected and correctness is still preserved when any accessor operation of the object is executed
This means that wherever we make use of 'GenericController' in our code it should be possible to pass in an instance of 'MoreSpecificController' and it would adhere to the same contract.
Our tests for each of the controllers looked a bit like this:
@Test
public void someTest() {
GenericController genericController = new GenericController();
genericController.handleRequest(null, null);
// and so on
}
@Test
public void someTest() {
MoreSpecificController moreSpecificController = new MoreSpecificController();
moreSpecificController.handleRequest(mock(HttpServletRequest.class), mock(HttpServletResponse.class));
// and so on
}
In 'MoreSpecificController' we need to get a value from the request which means that we can’t have it as null in the test. In 'GenericController' the request is actually irrelevant so we can pass in a null value.
This means that the pre condition for 'MoreSpecificController' is stronger than the pre condition for 'GenericController' which violates the principle of contravariance which states the following:
Every operation’s precondition is no stronger than the corresponding operation in the superclass. The strength of operation preconditions in the subclass goes in the opposite direction to the strength of the class invariant. That is, the operations preconditions get, if anything, weaker
The reason this might cause a problem is because if a client had a reference to a 'GenericController' they should expect that they can treat an instance of 'MoreSpecificController' as if it was a 'GenericController' which should mean that we can pass null values for request and response.
We weren’t ever referring to a 'GenericController' when we instantiated a 'MoreSpecificController' in our code base so it didn’t prove to be a problem but in theory it seems like something we’d want to avoid.
About the author
I'm currently working on short form content at ClickHouse. I publish short 5 minute videos showing how to solve data problems on YouTube @LearnDataWithMark. I previously worked on graph analytics at Neo4j, where I also co-authored the O'Reilly Graph Algorithms Book with Amy Hodler.