Using Guava Ranges to Implement Rules-based Logic
Consider the requirements of implementing an awarding mechanism based on goal completion percentage.
This kind of requirements is commonly found in loyalty programs. For example, if the user achieves between 70% to 79% of his/her original goal, the user gets a 10 points reward. Likewise, if the user completes 80% to 89% the user gets 15 points and so on. The basic idea is to have a set of percentage ranges associated with points. In addition, the percentage ranges/points combination must changeable to support evolving requirements.
I'm currently working on a Fitness Application, so I'm going to use this domain to illustrate a coding technique for Rules-based logic without using explicit IF statements. In this fitness application, we wish to award "badges" to an exerciser, based on the percentage of goal completion.
This kind of requirements is commonly found in loyalty programs. For example, if the user achieves between 70% to 79% of his/her original goal, the user gets a 10 points reward. Likewise, if the user completes 80% to 89% the user gets 15 points and so on. The basic idea is to have a set of percentage ranges associated with points. In addition, the percentage ranges/points combination must changeable to support evolving requirements.
I'm currently working on a Fitness Application, so I'm going to use this domain to illustrate a coding technique for Rules-based logic without using explicit IF statements. In this fitness application, we wish to award "badges" to an exerciser, based on the percentage of goal completion.
The first thing that comes to mind, is to implement a series of "if-else" blocks to determine the percentage range the exerciser is at according his goal completion:
int lookupPoints(int goal)
{
int points = 0;
if (goal >= 70 && goal <= 79){
points = 10;
}
else if (goal >=80 && goal <= 89){
points = 15;
}
else if (goal >=90 && goal <= 99){
points = 25;
}
else if (goal >=100 && goal <= 109){
points = 50;
}
else if (goal > 110){
points = 60;
}
return points;
}
This approach may be fine, but it is spaghetti code. As requirements change you may have to add or modify the existing range boundaries and point values.
Another approach to consider is leveraging a rules engine. I think using a rules engine, like Drools is overkill for this simple case.
Is there is a way to accomplish the same thing, without using any explicit "if"s while making possible to easily modify the ranges and point values?
Yes, after all you're reading this to find out how ! With this blog technique, there is no need to change the underlying decision making algorithm in case requirements change, all you need to do is to change the data setup, just like a fixture.
What kind of sorcery is this ? Guava libraries to the rescue!
Enter the Range class. (follow the link for it's Java Doc)
A range (or "interval") defines the boundaries around a contiguous span of values of some
Comparable type. So we can express all intervals in the code block above this way: Ranges.closedOpen(new Integer(70, new Integer(79));
Ranges.closedOpen(new Integer(80, new Integer(89));
Ranges.closedOpen(new Integer(90, new Integer(99));
Ranges.closedOpen(new Integer(100, new Integer(109));
Ranges.atLeast(new Integer(110));
What we need next is a way to associate each range with its "points" value.
We can use a Map for that like that:
Map<Range<Integer>, Integer> pointAwardMap = new HashMap<Range<Integer>, Integer>();
pointAwardMap.put(Ranges.closedOpen(new Integer(80, new Integer(89)), 10);
pointAwardMap.put(Ranges.closedOpen(new Integer(90, new Integer(99)), 15);
// ... etc, until
pointAwardMap.put(Ranges.atLeat(new Integer(110), 60);
Now that we have the point associated with a range, we need a way to do the look up based on the current goal value. That can be accomplish using the Guava Collections2 Filter class:
int lookupAwardPoints(final Integer completedGoal)
{
Collection<Range<Integer>> percentileRange = filter(pointAwardMap.keySet(), new Predicate<Range<Integer>>()
{
@Override
public boolean apply(Range<Integer> input)
{
return input.contains(completedGoal);
}
});
return percentileRange.isEmpty() ? 0 : pointAwardMap.get(percentileRange.iterator().next());
}
So, now that you know the though process, all that is left is to organize the code in a nice clean way, removing duplication and making it easy to add new rules:
1: import com.google.common.base.Predicate;
2: import com.google.common.collect.Range;
3: import com.google.common.collect.Ranges;
4: import java.util.Collection;
5: import java.util.HashMap;
6: import java.util.Map;
7: import static com.google.common.collect.Collections2.filter;
8: public class PointAward
9: {
10: {
11: addRangeAndPointAward(70, 80, 10);
12: addRangeAndPointAward(80, 90, 15);
13: addRangeAndPointAward(90, 100, 25);
14: addRangeAndPointAward(100, 110, 50);
15: addRangeAndPointAward(110, 60);
16: }
17: private Map<Range<Integer>, Integer> pointAwardMap = new HashMap<Range<Integer>, Integer>();
18: public int lookupAwardPoints(final Integer percentCompleted)
19: {
20: Collection<Range<Integer>> percentileRange = filter(pointAwardMap.keySet(), new Predicate<Range<Integer>>()
21: {
22: @Override
23: public boolean apply(Range<Integer> input)
24: {
25: return input.contains(percentCompleted);
26: }
27: });
28: return percentileRange.isEmpty() ? 0 : pointAwardMap.get(percentileRange.iterator().next());
29: }
30: private void addRangeAndPointAward(int lowerEnd, int pointAward)
31: {
32: pointAwardMap.put(Ranges.atLeast(lowerEnd), pointAward);
33: }
34: private void addRangeAndPointAward(int lowerEnd, int upperEnd, int pointAward)
35: {
36: pointAwardMap.put(Ranges.closedOpen(lowerEnd, upperEnd), pointAward);
37: }
38: }
Note that all you need to do if your rules change is to modify the "fixture" like code on lines 10-15, no need to ever change the looupAwardPoints() function. Also notice that we don't have any ifs, of spaghetti code.
Granted this code is way more sophisticated and complex than the first one, but it give you a lot of flexibility.
I hope, I showed you a useful trick!
Until next time!