Techconative Logo

Streamlining Conditionals:

  Harnessing Data

Fri May 26 2023

Streamlining Conditionals: Harnessing Data-Driven Approachimage

Context

The conventional method of representing a branching for deriving values or executing certain logics is by using an if-else statements to determine when to do what.

However, if-else statements can quickly build up to ladders and create code that is very hard to understand and maintain.

This blog is to share an approach that we came up with a systematic approach to avoid if-else else.

Existing solutions

Avoiding if-else ladders are not a new thought, for example, the book "Clean Code" talks about avoiding if else using polymorphism. But that solution is not practical in all the use cases, and we might end up in abusing polymorphism with that approach.

The approach that we came up with is also not a silver-bullet, but a practical data-driven approach which can be used when you can express your logic as tables.

The approach

Step 1: Make your conditionals as data table

Whatever the conditional logic that you have start by expressing the logic in tabular form.

For example,

The statement,

  • If color is 'Red' then 'Stop'
  • If color is 'Yellow' then 'Prepare to stop'
  • If color is 'Green' then 'Go'
  • If nothing else 'Unknown signal color'

Can be expressed as data-table like,

Signal ColorAction to be taken
RedStop
YellowPrepare to stop
GreenGo

Step 2: Encoding the datatable in code

Now that we have defined our conditionals in tabular format, lets see how can we express it as code.

Step 2.a: As Map

When you have a 2-column table, the simple way to encode the data would be a Map.

For example, the above signal table can be expressed as map like this,

var signalColorActions = Map.of( "Red", "Stop", "Yellow", "Prepare to stop", "Green", "Go" ); // And using it would be, signalColorActions.getOrDefault(signalColor, "Unknown signal color");

Which would be better than the equivalent counterpart with if-else ladder,

if ("Red".equals(signalColor)) { return "Stop"; } else if ("Yellow".equals(signalColor)) { return "Prepare to stop"; } else if ("Green".equals(signalColor)) { return "Go"; } else { return "Unknown signal color"; }
Step 2.b: When the key is not straightforward

If your conditional table is not straightforward key-value mapping, you would still achieve the same result with the strategy of encoding data table as Map with the key as Predicate, where the evaluation is about iterating through each key set and returning/evaluating the value when the corresponding key is evaluated to true.

For example,

For the data table,

Percentage (%)Grade
90-100A
80-89B
70-79C
60-69D
Below 60F

The corresponding Map can be,

Map<Predicate<Float>, String> grades = new LinkedHashMap<>(); grades.put(score -> score >= 90, "A"); grades.put(score -> score >= 80, "B"); grades.put(score -> score >= 70, "C"); grades.put(score -> score >= 60, "D"); grades.put(score -> score < 60, "F");

And the code to figure out the grade can be,

grades.entrySet().stream() .filter(entry -> entry.getKey().test(score)) .map(Map.Entry::getValue) .findFirst() .orElse("Invalid score");

Note: If the order of the evaluation of the predicates matters, you can go ahead with LinkedHashMap.

Step 2.c: When Map is not enough

Everything is fine until now when you have a 2-column table representation of your conditional logic. It would soon be problematic when you have multiple conditions to check for. In such cases, with Map approach, you might have to create nested maps, which would create complexity and defeats the purpose of the effort that we're working towards.

In such situations in which the Map approach is not enough, see if you can encode your tabular representation into bit more sophisticated data (for ex, as JSON).

For example, The logic,

if condition1 and condition2 then return value1 if condition1 and condition3 then return value2 if condition1 and condition3 and condition4 then return value3 if condition2 then return value4

Can be expressed in tabular format as,

Condition 1Condition 2Condition 3Condition 4Return Value
TrueTrueDon't CareDon't CareValue 1
TrueFalseTrueTrueValue 3
TrueFalseTrueFalseValue 2
FalseTrueDon't CareDon't CareValue 4
Don't CareFalseFalseDon't CareNo conditions met

And when you need to encode it in code, you can express it in JSON as,

{ "value1" : { "condition1" : true, "condition2" : true }, "value2" : { "condition1" : true, "condition3" : true }, "value3" : { "condition1" : true, "condition3" : true, "condition4" : true }, "value4" :{ "*" : true } }

And the equivalent code that evaluates can be,

var conditions = loadJson(); for (Map.Entry<String, Map<String, Boolean>> entry : conditions.entrySet()) { String value = entry.getKey(); Map<String, Boolean> conditionMap = entry.getValue(); if (allConditionsMatch(conditionMap, condition1, condition2, condition3, condition4)) { return value; } }

Note: Instead of imperative way of evaluating the JSON, you can also leverage on libraries that supports JQ so that the code that evaluates can also be made declarative.

Step 2.d: When the conditions represented in JSON gets complicated

If you're a situation where you find yourself in a position that you're unable to express the conditionals in a consistent way within the JSON, you can see if you turn to small DSL utilities like j-s-exp.

A more comprehensive example for this could be found in our earlier blog post.

Step 2.e: When a custom DSL gets too complicated

You can turn to formal rule-engine libraries that implements Decision Tables, which would be a straightforward mapping from data representation that you have done in step#1.

For example, libraries like Drools have features out-of-the-box that allows you to define and use Decision Tables from excel and use it.

Why all these?

  1. Separation of concerns: The "What" (business logic expressed as Map/JSON) and "How" (code leveraging the data) part of the code is separated out. So that all your business logic is encoded in code without other logics, and there would be a one-to-one mapping between your data representation and business spec.

  2. Flexibility: You can add, modify, or remove conditions without changing the code. This allows for easier customization and adaptation to evolving requirements.

  3. Testability: Since the conditions and actions are externalized, you can create targeted test cases, ensuring comprehensive test coverage without the complexity of testing nested conditions.

  4. Readability and Maintainable Code: Since the "What" and "How" parts are separated, when there is a change in business logic just change the data. When you want to optimize or bug fix, it would mostly be touching code that leverages the data representation.

Overall, this thought process helped us to write much cleaner and maintainable code. If you have similar thought process and stuck on simplifying your code, do talk to us. More than happy to discuss and help.

We would love to hear from you! Reach us @

info@techconative.com

Techconative Logo

More than software development, our product engineering services goes beyond backlog and emphasizes best outcomes and experiences.