Separate Use Cases
2021.01.31 - 2021.07.16
Last updated
2021.01.31 - 2021.07.16
Last updated
We are good programmers and we write good quality code. Despite that, I always struggle to find in the source code what I'm looking for. I usually feel like being in a maze. And it's worse: if I have found the demanded part, I lose it again... and again... and so on.
What happens here? What goes always wrong?
Programmers are notorious for pulling together similar code parts, saying they should avoid code repetition. Or that they should implement it on a more generic or abstract level.
Let's assume, we have two use cases consisting of similar but not equal steps:
Software developers will usually implement them in the following way:
That means that the common code will branch for the use cases, and then it goes again to the common code, then it will branch again.
With a simplified drawing—and rotating it with 90º—the following happens here:
Why is it bad? What is the problem? The problem is this:
The common code is not common!
The common part will contain different lines for the specific use cases again and again. So it is not really common. It is just "almost" common.
The common code should not be aware of the use cases at all.
The common code is often implemented in a parent class and the use cases are implemented in the child classes. But a parent class should not be aware of the children either.
Think of the program as a breakdown of the user requirements into features, use cases, and steps, where the use cases are made up by a pure sequence of steps, without branching.
Why is it good? Because it is much easier for the human mind to follow a sequence than several branchings.
What is the most simple and natural implementation of a sequence of steps? What would you expect, how it is implemented? The answer is:
The most simple and natural implementation of a sequence of steps is a sequence of method calls, without any branching.
How to achieve that the common code does not contain branching for the use cases? Of course, at a certain point, the program has to make a distinction between the use cases. But after this point we should never merge them again:
How do we eliminate the code duplication of the common steps? We can simply extract the parts which are equal to more use cases:
So the rule is:
Extract common code only if it is exactly the same for the use cases and does not contain branching for them.
It also means that the extracted common code parts should be independent. They should be distinct methods or classes.
Of course, a common code can contain branching for other use cases. But for those use cases, it should be again the only one place for branching.
You should only extract into common function how a certain step is implemented, and not extract that a use case contains that step. The steps of a use case make its description. The steps of another use case should be the independent description of that other use case, even if they are similar.
The naming rule can be helpful too: You should be able to add a meaningful, functional name to the extracted code. If it is not possible, then there is a chance that the code should not be extracted.
We can do this anti-pattern with simple method calls. We pass the use cases to the common code and then it will make a distinction between them.
But instead of this, we should avoid branching in the common code, which would make the implementation of the use cases clearer. Which code implements the comment better?
Note, that the step1()
and step2()
'working' methods are the same. Only the description of the use cases is different.
This is also a way we write common code with non-separated use cases. We make decisions based on certain information, so it looks like data processing. But it just hides the real use case. In the next example, the real meaning of the common code is this:
If attribute1 is not null then this is use case 1.
So it re-creates the use cases based on internal information. This is potentially dangerous too, since it may change. It is like trading on the basis of insider information, which is illegal in real life.
Note, that the situation is the same as in the previous example, and the solution is also the same.
The most correct solution is, that if Use cases 1 and 2 use different data, then different data types should be created for them.
We often do this common code mistake with abstract parent classes. Calling abstract methods is just an elegant way to hide the ifs/switches for the use cases, but they are still there! Each and every abstract method represents branching for the use cases.
It's even worse when we create multiple class hierarchies for the same use cases. They are really the same branching again and again.
Multiple class hierarchies for the same use cases should be treated as code repetition.
Solution: we should create only one class hierarchy for the same use cases. Even better if we do not create any class hierarchies for them. The best is if we Do Not Use Inheritance at all.
Putting common code into a parent class is a misuse of inheritance because it generates unwanted dependencies between the classes. Common code should rather be common functionality, which should be put in independent components.
We often use abstract methods only to return use case-specific values.
Why is this another incorrect usage of inheritance? Actually, it is an incorrect usage of methods, which should be procedures and not values. So the methods in the use case classes should directly call the specific methods instead of returning the use case.
In all the above examples there is a common issue: we control the program flow with some information, which is passed as input parameter, abstract method, data, or in other ways.
The following simplified code fragment shows where the if
command controls the program flow:
Why is it not optimal? Because in every example method1()
already knows the use case and it knows that doSomething()
should be called. But it postpones that and leaves it to method2()
. If method2()
is a common function than it is polluted by the branching between the use cases.
Never control the program flow with input parameters, abstract methods, or any fixed information, that is already known outside of the method.
Of course, branchings for the use cases should be implemented, but with the following rule:
Implement branchings for the use cases only once, and as early as possible.
In the entire code base, there may be many other merged codes. The problem is that they are not logical, since they do not come from business logic. Instead, they are unexpected and arbitrary. Every common code has its specific logic that must be understood by each developer who works on that part. This leads to the problem that is described in What Is The Problem With Abstract Frameworks?.
In other words, common codes dangerously increase the number of dependencies between the classes. Dependencies make the code hard to understand and maintain.
The following image shows a logical breakdown of a program into features, sub-features, use cases, sub-use cases, and steps. Ad-hoc common code can be dropped anywhere between any similar parts. Note, that in real life it would be much more complicated.
Note on the above image, that features may also have common code, which is even worse than use cases having it.
Features must not have a common code.
Features should be entirely independent of each other. Otherwise modifying a feature would risk the breaking of other features, which are already tested and delivered.
Not to mention that during the modifications of a program, features will have more and more differences, that must be added to the common parts, which adds unwanted complexity to the code.
When writing object-oriented programs with many classes, the big question is always: where should I add my code? And the answer is: the new code should have a well-defined functionality, that helps to name it, and then we can see where to add it.
Unfortunately 'common code' is not a name. It is not a functionality. We often create abstract parent classes for the common code. But 'Abstract' is not a name either, and abstraction is not a functionality.
Instead, when use cases or classes seem to have similar parts, we should analyze which functionality they have in common and organize the code according to that.
Why is the separation of use cases better than common code? We are never asked to modify 'common codes'. We always have to work on specific features and business requirements and that's what we want to see in the code.
In general, the goal is the separation, not the merge.
Extract the implementation of functionalities that are independent of the use cases, into a common code.
Do not extract the code, which is the description of the use cases, into a common code. Each use case should have its own distinct implementation, i.e. the description of the steps the use case consists of.
Never merge features into a common code. Features should be independent.
Implement branchings for the use cases only once, and as early as possible.
Organize and name the code by business logic, i.e. features, use cases, steps, and functions.
Branchings increase code complexity. This is also known as Cyclomatic complexity. Every if
or switch
command, every abstract method, or descendant class is a branching. In a real-life program, there are countless of them! This makes the code very complex and hard to understand.
Investigate every if
command and every possible branching, whether they are incorrect implementations of use cases, and rewrite them as shown above.
Common code creates a dependency between the classes. Dependencies also increase code complexity.