Back to Zopa Blog

Building a rule system in Ruby to evaluate loan applications

Every day many people apply for a loan at Zopa to make a positive change in their life. Some of these applicants are declined and, of these, a small minority are declined based on a set of rules developed by our team of data scientists (See The birth of Predictor). These rules have been coded into our core APIs.

The challenge

Recently while prototyping a new car loan product using the Ruby on Rails Web framework, we found ourselves dealing with many small rules unique to our product that were steadily appearing all over our codebase. This was less than ideal for several reasons, including having rule logic mixed into our ActiveRecord models (classes ideally reserved for handling data persistence and defining associations) and the difficulty of setting up tests to ensure all permutations of these rules were accounted for.

What we needed was a more intelligent way of organizing this code.

The rules in the application were varied, but they all have the same characteristics. They take some customer data as an input, process that input according to some logic and output either true or false depending on whether the rule applies or not.

As an example, let’s assume that somebody applies for a secured car loan. Before evaluating the creditworthiness of the individual, we need to know if the car is suitable. The user will input their car details from which vehicle information can be requested from a third-party API. If the response indicates that certain aspects of the car’s history or current status aren’t acceptable under our risk criteria, a rule can immediately decline the loan application. These rules simplify the application process, but in the initial development stage we found them an ever-growing source of complexity.

This is the story about how we managed to bring control back to our code base.

How we arrived at a solution

Initially, to define the rules, we started by creating many private helper methods that would return true or false, depending on whether the rule raised cause for concern or not. We then had a public method that would keep a list of all the helper methods within the class and iterate this list, calling each method in turn. Should any helper return false, the whole instance would have deemed to have failed the rule set and would not be allowed to proceed.

class Car  def rules_passed?   rules = %i[acceptable_age?, registered_in_uk?]   rules.each {|rule| self.send(rule) }.select {|rule| rule == false } end  private    def acceptable_age?   …   end    def registered_in_uk?    …   end end

While this worked, it reduced cohesion in the class and increased complexity. We needed to simplify this situation before our code became littered with small methods, each trying to determine the eligibility of a small aspect of a particular class instance. With a comprehensive test suite in place, we set out to refactor the rule code into something more maintainable.

Introducing The Judge

It became apparent that what we needed to create was a class that could be instantiated with a user and a set of rules. This object would apply the rules and return the outcome. The basic structure of what came to be known as the Judge class is as follows:

module AutoDeclineRules class Judge    attr_reader :user, :rules    def initialize(user, rules)     @user = user     @rules = rules   end    def decline?      fired_rules.any?.tap do |result|        Rails.logger.info “Declined user #{user.id} because of #{fired_rules}” if result      end   end    private      def fired_rules        rules.select do |rule|          rule.fired?        end     end   end end

Defining the rules

In order to instantiate the Judge, we would need a set of rules. We decided to keep these rules scoped to a particular class, e.g. the Car class. Each class we wanted to apply rules to would have an associated class to manage the rules:

module AutoDeclineRules class Car     attr_reader :car, :rules      RULE_LIST = [       AcceptableAge,       &hellip;    ]      def initialize(car)       @car = car       @rules = initialize_rules     end      def initialize_rules       RULE_LIST.each_with_object([]) do |rule, list|         list << rule.new(car)       end     end      def to_a       rules     end   end end

We then needed to create the actual rules to apply, but as they shared some common functionality, we extracted out a parent class:

module AutoDeclineRules    class CarRule       attr_reader :car       def initialize(car)        @car = car      end       def fired        yield        rescue => e          message = &ldquo;Failed to evaluate autodecline rule #{self.class.name} for #{car.id}&rdquo;          Rails.logger.error message          raise message      end   end end

This CarRule class could then be used to define the actual rules we wanted to apply.

module AutoDeclineRules   class AcceptableAge < CarRule     def fired       super do         # rule logic goes here       end     end   end end

Applying the rules was then a simple matter of putting this all together:

AutoDeclineRules::Judge.new(   user,   AutoDeclineRules::Car.new(self).to_a, ).decline?

Conclusion

By following this approach, we were able to dramatically clean up our code by placing all the rule logic into separate files. The result was a return to the Object Orientated design principles of open / closed and single responsibility. It now became easy to add new rules to the system and clear where they should go. A big advantage to this approach is that the solution is highly testable; for any given rule we could create a car that fires the rule and test in isolation that it actually fired.

If you have implemented something similar, please let us know what you think by dropping a line at techtone@zopa.com.

(Image credit to elliottcable)