Sometimes it’s not obvious where a method should live. Does the spaceship hit() the asteroid, or does the asteroid hit() the spaceship?
We can flip a coin… or we can step back, and look to see if the ambiguity is actually telling us about a concept that’s missing from our model. In today’s lesson, we’ll see how paying attention to these ambiguously-homed methods can lead us to extract business rules as first-class objects in their own right.
Food for thought: When was the last time you couldn’t figure out which object a method should belong to?
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
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.