Back to Zopa Blog

Building a rule system in Ruby to evaluate loan applications

Posted on 25 Jan 2017 by Greg Baker

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,
      …
   ]

    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 = “Failed to evaluate autodecline rule #{self.class.name} for #{car.id}”
         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)

Category: Tech
Tags: ruby

We're here to help

Monday to Thursday (9am to 5:30pm), and Friday (9am to 5pm).

Email: contactus@zopa.com

Telephone: 020 7580 6060 for loans

Telephone: 020 7291 8331 for investments

We can't take applications over the phone. UK residents only. Calls may be monitored or recorded.