Your business rules are objects too

Have you ever struggled to place a method that seems like it belongs equally well in either of two different classes? In this screencast you'll learn an approach to resolve this conundrum once and for all.

Today we're writing software to manage an airline frequent-flier program. Frequent flier programs are full of interesting business rules around who has earned what kind of status, and what sort of perks that status entitles them to.

We've already identified a few obvious objects in this domain. First of all, there is the Member. Members accumulate miles and other qualifying stats, like dollars spent and flight segments.

class Member
  attr_accessor :miles
  attr_accessor :partner_miles
  attr_accessor :dollars
  attr_accessor :segments
end

And then there are various tiers of frequent flyer rewards. Our airline has Bronze, Silver, and Gold levels. Each tier defines different goodies and perks available to members who qualify for it. It also has associated stat milestones that are required for a member to achieve that tier.

class BronzeTier
  def beverages
    "free beer"
  end

  def boarding_group
    "priority"
  end

  def required_miles
    25_000
  end

  def required_segments
    30
  end

  def required_dollars
    3_000
  end
end

class SilverTier
  def beverages
    "free tequila"
  end

  def boarding_group
    "super priority"
  end

  def required_miles
    50_000
  end

  def required_segments
    60
  end

  def required_dollars
    6_000
  end
end

class GoldTier
  def beverages
    "free champagne"
  end

  def boarding_group
    "teleport directly into your seat"
  end

  def required_miles
    75_000
  end

  def required_segments
    100
  end

  def required_dollars
    100
  end
end

We also need some code that determines if a member is eligible for a given tier. The rule for this is a bit convoluted. For a given level, a member must have flown the required number of miles, or the required number of segments. In addition, they must have spent the required number of dollars on tickets.

A member may collect some of their miles from traveling with partner airlines. But these “partner miles” are capped at 10,000, and we have to factor this into our calculations.

require "./member"

class Member
  def eligible_for?(tier)
    (((miles + qualifying_partner_miles) >= tier.required_miles) ||
     (segments >= tier.required_segments)) &&
      (dollars >= tier.required_dollars)
  end

  def qualifying_partner_miles
    [partner_miles, 10_000].min
  end
end

We put this code into our Member model, because, well, it seemed like the right place at the time. It kinda makes sense to say: “member, are you eligible for this tier?”

m = Member.new
tier = BronzeTier.new
m.eligible_for?(tier)

But after when we come back the next morning, we start to have second thoughts. Member is one of our core models, and it's constantly in danger of growing over-large and complex. The eligibility rule seems to make use of information from both member and tier objects equally. And if Member really does represent a member of our program, would we actually ask a member if they were eligible?

We play around with what it would look like if things were switched around, and the eligibility rule was in the tier instead.

member = Member.new
tier = BronzeTier.new

member.eligible_for?(tier)
tier.earned_by?(member)

The more we look at these lines, the more we're not sure. Each one kinda makes sense. Each one means adding logic to a class that already has other responsibilities.

In my opinion, this situation—where there are two classes which seem equally appropriate as a home for new logic—is a code smell. It's a danger sign that we may have left out a piece of our domain model.

If this were a detective story, this would be the part where the protagonist thinks back over everything they have seen and heard, trying to find the nagging clue that they know they missed along the way. Let's turn our mental ears inward.

…the rule for this is a bit convoluted…
…and the eligibility rule was in the tier instead…
…the eligibility rule seems to make use of…

Aha! There it is! We keep referring to a noun, “rule”. But nowhere in our application code is this noun represented as an object!

Traditional data-oriented class modeling encourages us to make rules into methods that attached to the objects where the information they need can be found. But if we take a more behavioral view of our objects, we realize: rules can be first-class parts of our domain model!

Let's build an eligibility rule as a class. We give it member and tier attributes, which are filled in on initialization. Then we give it a #satisfied? predicate, and move in the logic from the Member class. We update the code to ask the member for various needed attributes.

class MemberEligibleForTierRule
  attr_reader :member, :tier

  def initialize(member:,tier:)
    @member, @tier = member, tier
  end

  def satisfied?
    (((member.miles + member.qualifying_partner_miles) >= tier.required_miles) ||
     (member.segments >= tier.required_segments)) &&
      (member.dollars >= tier.required_dollars)
  end
end

After looking at this new class for a moment, we realize that it can be an attractor for other, related methods. Our #qualifying_partner_miles method can be moved in, leaving the Member class un-sullied by eligibility logic.

class MemberEligibleForTierRule
  attr_reader :member, :tier

  def initialize(member:,tier:)
    @member, @tier = member, tier
  end

  def satisfied?
    (((member.miles + qualifying_partner_miles) >= tier.required_miles) ||
     (member.segments >= tier.required_segments)) &&
      (member.dollars >= tier.required_dollars)
  end

  private

  def qualifying_partner_miles
    [member.partner_miles, 10_000].min
  end
end

Now when we want to check if a member is eligible for a given tier, we instantiate the rule, giving it the member and the tier, and then ask it.

rule = MemberEligibleForTierRule.new(member: member, tier: tier)
rule.satisfied?

We've come up with a definitive answer to the question: “which object does this logic belong in?”. Our answer is: neither. Eligibility tests represent just one context that members and tier objects might be used in. It doesn't make sense for either a member object or a tier object to be carrying around eligibility logic even in contexts where it is irrelevant.

In addition, we've helped prevent uncontrolled class expansion. If this rule grows any more complex, and requires more helper methods, that growth will now happen in a dedicated class, rather than bloating up the Member or perk tier classes.

The next time you hear yourself or your domain experts talking about rules, think about whether those rules might be best represented as their own objects, rather than as methods tacked onto existing domain concepts. Happy hacking!