Poro Service Objects

Background

Poro: Plain Old Ruby Object

  • An object which doesn’t depend or inherit from classes that are not part of ruby
  • In the Rails world this means objects (classes) which are not derived from ActiveRecord (especially) or any of the other Rails framework classes
  • Poros are easier to write and easier to test
  • The discipline of desinging Poro’s almost automatically leads you to better isolation and separation of concerns.
  • Poros are defined in standalone ruby files called, e.g. matcher.rb.
  • Put them in any Rails directory under app. I suggest creating a new directory under app called lib/ but it’s not the only option

Service Objects

  • The terminology here is currently muddled
  • You will find ardent debates between terms like Service or Presenter or Facade classes
  • In rails a very common use case is to make controllers simpler and better encapuslated
  • Remember that Rails helpers are meant to be shorthand or macros for generating html in views.

Example: Matcher

  • Imagine that you are writing a dating app where you want to match Members to each other
  • Given a Member model or a Member object, it will compute the Member that best matches them.
  • This can be a pretty complicated algorithm, taking as input one Member object as well as the complete list of available (unmatched) Members and producing as output another Member object, something like this:

@target_member = Member.find(i)
# .... complicared algortithm which ends with
@target_member.match = ...another member...
  • Having this much code in the controller smells bad!

Instead try this

  • Define a poro called Matcher which does not depend, itself or in the arguments it takes or the results it generates, on anything in Rails

class Matcher
  def add_target_member id, species, genus
    @target_species = species
    @target_genus = genus
    @target_id = id
  end

  def add_population members
# members is an array structured as follows:
# [[1, "Genus1", "SpeciesA"], [10, "Genus2", "SpeciesB"], ...]
    @population = members
  end

  def find_best_match
    # ... fancy algorithm
    return best_match_id
  end
end

Usage

  • In the controller, or wherever you need it, you create an instance of the Matcher
  • You retrieve the members from the database using ActiveRecord
  • You convert them to non-ActiveRecord arrays
  • You supply them to the matcher and then ask for the result

# Get the set of all members that don't have a match yet
unmatched_members = 
  Member.where(matched_member: nil).to_a.map(&:serializable_hash)
match = Matcher.new

# assume that the member object who we are trying to match up is "looking_member"
match.add_target_member_id( looking_member.id, looking_member.species, looking_member.genus)
match.add_population_members = unmatched_members

# Store the best match as in "looking_member"
looking.matched_member = Member.find(match.find_best_match)

Benefits

  1. You have pulled the logic for matching out of the controller into a separate class
  2. You can have several different matching algorithms and change them out cleanly by modifying the line match = Match.new to be for example match = FancyMatcher.new
  3. You can test class Match very simply, including generating randome combinations of data to see that it works correctly