Evolution: From Strategy Pattern to Functional Concepts in Ruby

puzzled cat The problem I have with functional programming concepts is that whenever I learn about them, it’s usually about monads, closures, folds, infinite streams etc. I know they are cool and all but, honestly, I rarely see a good use for them in my daily work. I am a Ruby dev mostly; I like to get stuff done without too much ceremony. And I really like OO, despite all its shortcomings. There are times, however, when situations call for something better.

This is a story of how we ended up with pretty cool functional code in an evolutionary way.

Context

We have a project, which is a tool that enables users to get a mortgage online. Actually, it’s the first app that lets you get a mortgage sitting at home in your pajamas. 100% online.

Firstly, customers fill in lots of data about themselves and the property, then the system presents a list of mortgage offers from various banks. To get those offers as exact as possible, we need to calculate many determinants (things like property pledge, financial portability, amortisation, etc.). These calculations are pretty straightforward, but there’s a lot of them. Moreover,  they’re interconnected: results of some may be used in following calculations down the road. Finally, we end up with few important values (which determine the final mortgage decision) and a considerable amount of data, all of which needs to be stored e.g. for presentation purposes.

MVPs are like cheap wine

Cheap wine is good because it’s good and cheap. So are MVPs. You’ll want something better much sooner than you’d expect.

At first there only were 2 banks and one robust algorithm that was separated into many classes for clarity. Then, as we added more banks to the platform, things started to get complicated. Various requests from clients began to emerge: calculate retirement age differently, use gross income instead of net income, divide instead of multiply, etc. Different banks had different formulae for things. You get the idea.

Enter the strategy pattern

We started spinning-off parts of the algorithm to separate classes and injecting them dynamically into the main template. Nothing unusual – classic strategy pattern. It all looked good. But it grew and grew, and then it grew just a tad to big. The code became messy and unreadable. Strategies started to have their own sets of strategies; layers of abstraction were multiplying like crazy and it was killing us. For newcomers to the project, it was almost impossible to understand what was going on. The domain knowledge was lost between the lines. The bus factor plummeted.

The project was live and starting to generate income, but adding each new bank to the platform was taking 1-2 weeks. It was a crucial process for the business and it simply took too long.

It never rains but it pours

As if this wasn’t bad enough, then came a real bummer. A new feature request for a view with a summary of all the calculations. Now, not only did we have to save all the numbers, but now we also had to persist the formulae used to calculate them… How can we add another layer to this already messy code?

We didn’t. We created a separate set of decorators just to handle this. It worked for the moment, but now the knowledge was in two separate parts of the system. We were facing a shotgun surgery issue on top of all the previous problems.

We realised, it’s time to take a step back and reassess.

After talking to our client, we decided that we are going to spend some time to refactor and pay back part of the technical debt.

cat to lion function drawing

Back to square one

Refactoring started with gathering the requirements:

  1. We have initial data, mostly numbers, and booleans coming from user input.

  2. We have an ordered list of calculations to be performed on the input data. These are our previous strategy objects.

  3. We should be able to reuse results from all calculation steps.

  4. We need all the results in the end.

  5. For some of the results we need not only values, but also the formulae.

  6. We need all of the above to be as flexible as possible. When a new bank joins the table, we should be able to adjust independent parts of the algorithm without too much hassle.

Input and output - united we stand

The input hash:

After calculations the output will look like this:

Strategy objects

What are the strategy objects we’ve been using so far? They are the atomic pieces of the algorithm. Like steps in a cake recipe. Do we really need the OO boilerplate? Strategies could be stateless, so why not just use functions? Oh, it’s Ruby. There is no first-class function concept. Perhaps, we could use lambdas. But we’d like to get the strategies tested and possibly reuse some of them for various banks. How about modules with one static call method? Since we are passing entire data hash to each calculation function, we need to “swallow” unnecessary keys. This is a moment when Ruby’s keyword arguments and the double splat operator come in handy. Dig it, it’s awesome.

Banks

Bank parameters are an example of externally configurable factors, you can get them from the DB for instance. The evaluation steps are what’s interesting here. It’s a line up of calculations to be performed on data. This is each bank’s recipe for a the final answer.

Putting it all together

This is our entry point:

Let’s roll. We pass the hash from one step to the next one, using inject method. Each one is taking whatever it wants from the hash, working on it and adds the result as a new key-value pair to the hash. In the very end, it’s all in the final hash (it smells a bit of primitive obsession, but let’s keep it simple for the sake of example).

New boys in town

When you need to add new strategies, which may be different for the banks, you’ll do it like this:

Fail Better

One problem that we’ve encountered, were ambiguous error messages when strategies couldn’t find the required key. 

With a little bit of ruby magic we’ve managed to improve that.

Now, when you get the error, you know exactly where to dig:

Final thoughts

sleeping cat The solution meet requirements mentioned above. It looks simple and indeed it is. Not only it is easy to use but also elegant and extensible. We’ve been using it in production for 4 months and haven’t encountered big issues so far. What’s more important, however, we have successfully reduced the time needed to add a new bank to the platform from 5-10 days to 2-4 days. It’s something.

Additionally, testing is now super easy. You can unit test each atomic strategy independently.

As a bonus, if you’re as lazy as we are, you can always make an inline strategy by defining a lambda in the evaluation steps template like this:

Of course, this is not a silver bullet and has issues of its own.

The high connascence of name for input and output keys is the biggest problem. We work around that with one integration test for each bank to make sure that we’ve got good coverage. Another problem is that modules can’t have static private methods, which would be helpful for some more complex strategies. Should you see any other issues, please let us know in the comments.

All in all, it’s been an interesting exercise for us and a one which proves how flexible and fantastic the Ruby language is.

Please do share your thoughts in the comments.

PS. If you’re wondering what happened to the formulae requirement, stay tuned. We’ll cover that in the second part.

Related posts: