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!
I think it worth to mention that we have Mediator pattern which describes your case with Rule object https://en.wikipedia.org/wiki/Mediator_pattern
Thanks for noting that!
—
How would we do it if the satisfied? conditions for each tier are different?
eg For Bronze it would be dollars spent, but for Silver it would be miles flown?
Would we have separate classes?
like MemberEligibleForBronzeTierRule and MemberEligibleForSilverTierRule
Or would it be different methods in the MemberEligibleTierRule
like bronze_satisfied? and silver_satisfied?
I personally like the first way, then we could have a satisfied method for all three classes
For me there is no point in having separate classes. If you want to have one rule for each tier, I think it is better to have the rule embedded in the tier class itself.
I love how you only need to spend 100 dollars to get the gold tier. Now if only i can find a 750 mile segment to fly for 1 dollar 100 times…
No “download” option this time?
Hiya! This is the public “freebie version” of an original episode. The download is attached to the original, not to the public version.
—
Why would you let the public download a full episode if they only get to see part of it?
Maybe try finding the original, via search bar, I know my dad would have added one.
This approach kind of reminds me to the “Policy objects” in https://codeclimate.com/blog/7-ways-to-decompose-fat-activerecord-models/.
The analogy with the detective scene was super fun hehe!
Great episode!
Love the part where you edited the video like in a detective movie!