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 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.
Responses