0

I have some code where I believe the SLA (single level of abstraction) principle is broken in a way that the low-level code (if-statements) is outside and the high level code is nested inside the cases. A simplified version of the code is:

public void GenerateReport(int effort, bool delayed)
{
    if (effort < 5)
    {
        GenerateSimpleReport();
    }
    else if (effort < 20)
    {
        GenerateNormalReport();
    }
    else if (delayed)
    {
        GenerateDangerReport();
    }
    else
    {
        GenerateAwesomeReport();
    }
}

I would like to have the individual conditions for the different kind of reports at the abstraction level of those different report types, not at the abstraction level of the caller. I still don't want to move the condition tests into the Generate...Report() methods directly.

Which approach (maybe a design pattern) would be helpful here to refactor this code so that the SLA principle would be obeyed? If available, I'd like to use a well-known, documented pattern, but I am also open for other ideas.

Regarding the suggested duplicate: If I apply the Specification pattern, the code probably looks like

if (simpleSpecification.IsSatisfiedBy(effort, delayed))
{ 
    GenerateSimpleReport(); 
}
if (normalSpecification.IsSatisfiedBy(effort, delayed))
{ 
    GenerateNormalReport(); 
}
if (dangerSpecification.IsSatisfiedBy(effort, delayed))
{ 
    GenerateDangerReport(); 
}
if (awesomeSpecification.IsSatisfiedBy(effort, delayed))
{ 
    GenerateAwesomeReport(); 
}

and I don't see how this solves the problem, the conditions are still separated from the GenerateXxx() methods.

Doc Brown
  • 199,015
  • 33
  • 367
  • 565
Thomas Weller
  • 221
  • 4
  • 13
  • Does this answer your question? [Style for control flow with validation checks](https://softwareengineering.stackexchange.com/questions/148849/style-for-control-flow-with-validation-checks) – gnat May 19 '21 at 08:21
  • 3
    The code does exactly one thing: select the correct report type based on effort and delay. I see no violation of any principle here. However, there may be an Open/Closed violation *if* report types need to be pluggable (often not important though). It would also be possible to argue that this function should merely select the report type and not perform the generation as well. Instead, it could return some kind of ReportGenerator object. This could simplify testing! – amon May 19 '21 at 08:29
  • @amon: Reading R.C.Martin's Clean Code, I consider this if/else to be much like a switch statement. R.C.Martin says (chapter 3) that a switch statement always does more than one thing. He then puts the code into an abstract factory and returns an object. But IMHO, I haven't won much except that there's a single place for the switch logic in case I had multiple (which we don't know in his example and isn't the case in my example). So, the answer might be: rename this class to AbstractFactory and everythig is fine? Can't believe... – Thomas Weller May 19 '21 at 09:02
  • 1
    @ThomasWeller: In that context Martin is only talking about switch statements which select an action based on some 'type' enum. This is indeed an anti-pattern in OO, since you should use overloading instead. But this is not at all what your code does. – JacquesB May 19 '21 at 09:16
  • 2
    An Abstract Factory would introduce a lot of complexity that is likely unwarranted here. A switch/case or if/else is PERFECTLY FINE. A lot of design patterns only make sense in certain settings, such as libraries that have to prepare for future evolution in a backwards-compatible manner. If this is an application you can refactor at any time: YAGNI. Also, “Uncle Bob” is usually too fundamentalist/extreme in his recommendations. This can be a good didactic tool, but lacks nuance. His technical recommendations are also questionable in itself, e.g. see https://qntm.org/clean – amon May 19 '21 at 09:19
  • 1
    Your question is not written well and missing information, that is why you got these former responses. Looking at your own answer, it seems you are looking for a solution where a report could be build from multiple sections, and the condition test for each section is moved away from the report combinator to the code which belongs to the section. I heavily recommend to rewrite the question that way, and **ask** if it violates the SRP and SLA, not *assume* it does. When you do so, be careful not to invalidate JacquesB's answer (or at least inform them about the changes). Or let me make a try ... – Doc Brown May 20 '21 at 04:26
  • 2
    ... ok, please double check if I got your intentions right. Note it is a not a good idea to insist on a "pattern" solution on this site, most experts here agree upon patterns leading often to overdesign. – Doc Brown May 20 '21 at 04:41
  • @DocBrown: thanks for the edit – Thomas Weller May 20 '21 at 06:40

3 Answers3

5

The first code with if-else looks fine to me. The logic is clearly expressed, which means the code is easy to understand and maintain.

If-statments are not "low level code". They operate at the abstraction level of the conditionals, in this case the abstraction level of "effort" and "delayed", which is the parameters which define which report is appropriate. So it seems to me everything is on the same abstraction level.

A SLA violation would be if the same code also (say) performed the actual generation of the reports into html or pdf. These are concerns at a different abstraction level than selecting the correct report.

Moving the condition checks into separate function or classes would just complicate the code and make it more bug prone. Because the conditions are not actually independent - they depend on whether a previous report condition matched or not. For example the condition for DangerReport is that delayed is true and that effort < 20. Should the DagerReport condition check for effort < 20 or should it rely on the prioritized order of checking the report conditions? In any case the logic determining which report to generate is now spread in multiple different places.

For example the final else provide the guarantee that a report is always generated. None of the more complex solution suggested actually provide this basic guarantee. But this is easy to miss because the increased complexity makes the logic harder to follow.

With the single if-else you have high cohesion - the logic for selecting the correct report in one single place, easy to read, understand and modify. By breaking it into separate functions give you low cohesion and high coupling, since the conditions are still logically interdependent. This makes the code hard to follow and easy to introduce bugs.

(If the conditions actually were independent, i.e. each report decided individually if it should be generated, regardless of whether other reports are generated, then it would make sense to separate the conditions. But this would mean you could get zero or multiple independent reports, not a single report as in the original code.)

It is easy to miss the forest for trees when reading about principles and patterns. In the end it comes down to if the code is easy to understand and easy to modify when things change. This seem to be the case for your code. Turning it into a "abstract factory" or whatever will in no way improve the readability or maintainability.

JacquesB
  • 57,310
  • 21
  • 127
  • 176
  • Seeing the OPs own answer, I took the freedom to rewrite his question a little bit. I think your answer still fits, but maybe you find some time to double-check it. – Doc Brown May 20 '21 at 04:38
  • @DocBrown So you made up a requirement to justify the more convoluted code? This is not how SO is supposed to work. More importantly, this is not how software development is supposed to work. – JacquesB May 20 '21 at 07:51
  • No, I looked at the OP's own answer and made an educated guess what the OP could have really meant, then I made an edit and asked the OP for confirmation if my guess was right, which they confirmed. And yes, asking for clarifications, reading between the lines, making suggestions and communicating things, that's how software development works. And I upvoted your answer, too, and I agree, the more convoluted code of the OP is still not justified, even with the reworded question. I am preparing an answer by myself. – Doc Brown May 20 '21 at 07:56
  • @DocBrown: I believe you are abusing the system by editing the question to fit your answer. You can easily answer any question that way, but that is just turning SO into your personal blog. – JacquesB May 20 '21 at 08:35
  • As I said, I asked the OP what they meant (to make it fit better to *their own* answer, not to mine), asked for confirmation and got one - then I wrote my answer, afterwards, in that order. And as I wrote, I don't think it invalidates your answer, it is still a good one. – Doc Brown May 20 '21 at 08:39
2

Reading your own answer, I think the code still violates another important principle: KISS.

The most simple and straightforward approach to move the conditions to a level of abstraction of their own would be to refactor the conditions into separate functions:

public void GenerateReport(int effort, bool delayed)
{
   bool done=false;
   done = done && GenerateSimpleReportIfApplicable(effort);
   done = done && GenerateNormalReportIfApplicable(effort);
   done = done && GenerateDangerReportIfApplicable(delayed);
   done = done && GenerateAwesomeReportIfApplicable();
}

bool GenerateSimpleReportIfApplicable(int effort)
{
    if (effort < 5)
    {
        GenerateSimpleReport();
        return true;
    }
    return false;
}

bool GenerateNormalReportIfApplicable(int effort)
{
    if (effort<20)
    {
        GenerateNormalReport();
        return true;
    }
    return false;
}
// ...

Now, this gives you several opportunities for further refactorings:

  • making the IfApplicable functions all conform to the same signature, so you can put them all into an array of functions, where GenerateReportcan loop over them. This may be worth the effort if there are several more of them, not just four.

  • remove the boolean return value from the IfApplicable functions, rework the conditions, allowing to generate a full report from several sub-reports, if you can modularize the reports that way (as scetched in your own answer).

  • move the IfApplicable functions into different or separate classes, may into a different library, so someone else can maintain them in parallel, without touching the main GenerateReport function any more. This makes sense if you want to make it easier for several people to work at different reports in parallel.

  • making those different classes all conform to the same interface, and maybe end up with your idea of a Chain-Of-Responsibility. This makes most sense if you want to create an extensible report generation framework.

But beware: this additional complexity always comes for a cost which must be justified by a reason. The SRP, or the SLA (at least how you interpreted it), and other principles and patterns are not an end in themselves. The guideline when to apply which should be YAGNI: only use the above refactorings and flexibilizations when you notice the current code forces you to maintain things which belong together in different places, violates DRY too much, is not testable, or does not fulfill other non-functional requirements. Otherwise, do yourself a favor and keep the code as simple as it was originally.

Doc Brown
  • 199,015
  • 33
  • 367
  • 565
  • In the original question, only one report was generated, but in this answer multiple (or none) reports can be generated. – JacquesB May 20 '21 at 08:46
  • 1
    @JacquesB: not if the conditions are adapted correctly (as the OP also did in their answer). See also my suggestion about using boolean return values above. – Doc Brown May 20 '21 at 08:48
  • The use of an if-else in the original code guarantees that only one report is generated, even if multiple match the conditions. The order of the clauses indicate the priority. The final else is the "fallback" report which is generated if none of the other reports match the conditions. None of these constraints are present here. – JacquesB May 20 '21 at 09:30
  • @JacquesB: ok, I give up, I turned my two alternatives around, starting with the one which matches more the original code and scetching the other one in the additions below. – Doc Brown May 20 '21 at 09:51
  • You still don't have a guarantee that a report will always be generated - something which is ensured by the use of `else` in the original code. Why not just use an if-else when it exactly matches the desired logic? – JacquesB May 20 '21 at 10:03
  • @JacquesB: now you are overthinking this ;-) – Doc Brown May 20 '21 at 10:51
-2

I will consider the comments saying that the code is fine.

However, in my particular case, I think I found a combination of the Chain of Responsibility pattern along with a Template Method makes the code conforming better to my requirement.

The affected method becomes nicely SLA without any low-level ifs:

public void GenerateReport(int effort, bool delayed)
{
    var report = new DangerReport()
        .SetNext(new AwesomeReport())
        .SetNext(new NormalReport())
        .SetNext(new SimpleReport());
    report.GenerateReport(effort, delayed);
}

and I have a base class allowing to chain the different reports (Append()) and do the work (HandleRequest()). Each condition goes into the IsApplicable() method and it's impossible to bypass the checks due to the template method pattern inside HandleRequest().

abstract class ChainableReport
{
    private ChainableReport successor;

    public ChainableReport SetNext(ChainableReport nextReport)
    {
        if (successor == null)
            successor = nextReport;
        else
            successor.SetNext(nextReport);
        return this;
    }

    public void GenerateReport(int effort, bool delayed)
    {
        if (IsApplicable(effort, delayed))
            GenerateReport();
        else if (successor != null)
            successor.GenerateReport(effort, delayed);
    }

    protected abstract bool IsApplicable(int effort, bool delayed);
    protected abstract void GenerateReport();
}

Yes, I certainly have introduced a higher coupling between the report and its condition. On the other hand side I am now able to change the order of the reports freely and let the most important one decide first whether or not it wants to handle the case or not. Note that I was able to put new DangerReport() in first position where it was in third position in the if-else cascade.

Thomas Weller
  • 221
  • 4
  • 13
  • 4
    I just counted the lines of code... 9 lines for the original (with my coding standards), yours is 20 lines, and a new class, and the reports are forced to conform to some abstract class that does nothing but form a linked list of reports - putting those reports unmodified into an array would be much better. Plus the reports now have to know whether they should be produced which _is_ a violation of the SRP. I wouldn't be happy with this in a code review. – gnasher729 May 19 '21 at 13:07
  • "Chain of Responsibility" would perhaps be appropriate if you had some kind of framework where multiple 3rd party components independently could "hook up" and generate reports under certain conditions. Outside of this use case, it seem to be worse in almost any way. – JacquesB May 19 '21 at 14:20
  • 1
    I don't get the downvotes, but I do have to caution that this proposed solution seems very “Architecture Astronaut”-y to me. The Chain of Responsibility patterns seems to have been applied without concern for whether this fixes any non-hypothetical problem. Per the Design Patterns book, this pattern might be appropriate when “the set of objects that can handle a request should be specified dynamically”. Yet here, the set is statically known, as in the original code using if/else. And as pointed out by preceding comments, the resulting code is more complex and more fragile. – amon May 19 '21 at 20:45
  • @gnasher729: I rewrote the original question a little bit, I guess moving the knowledge of whether a report shall be produced into the report code is exactly what he was looking for. – Doc Brown May 20 '21 at 04:40
  • 2
    @amon: the reasons for the downvotes to the former version were IMHO pretty clear - without stating the shift of the conditions as *requirement* in the question, this answer appeared overdesigned, as you wrote by yourself. I took the freedom to change the question a bit, now the answer should better fit to it. – Doc Brown May 20 '21 at 07:20
  • This looks like it violates the principle of least surprise to me. It appears at first glance like the final report output is a UNION of those 4 reports. – Graham May 20 '21 at 19:58
  • @Graham I think I simplified the situation too much. The reports were all separate. After the refactoring I wanted them to still be separate. In the end I probably want to split a report into sections, each of which has some conditions whether or not it will be included. Like a we have a traffic light, a management summary, problem statistics, effort discussion etc. When I reach that point, it will be a union. To get there, it takes multiple steps. – Thomas Weller May 21 '21 at 05:54
  • @gnasher729: possibly one would have moved the methods for generating different types of reports into separate classes before. That way I wouldn't have introduced many new classes. Putting the reports into a list and iterating over it is a different implementation of the CoR pattern. I sticked closely to the UML diagram. – Thomas Weller May 21 '21 at 06:27