Ruby Science
Replace Conditional with Null Object
Every Ruby developer is familiar with nil, and Ruby on
Rails comes with a full complement of tools to handle it:
nil?, present?, try and more.
However, it’s easy to let these tools hide duplication and leak
concerns. If you find yourself checking for nil all over
your codebase, try replacing some of the nil values with
Null Objects.
Uses
- Removes shotgun
surgery when an existing method begins returning
nil. - Removes duplicated
code related to checking for
nil. - Removes clutter, improving readability of code that consumes
nil. - Makes logic related to presence and absence easier to reuse, making it easier to avoid duplication.
- Replaces conditional logic with simple commands, following tell, don’t ask.
Example
# app/models/question.rb
def most_recent_answer_text
answers.most_recent.try(:text) || Answer::MISSING_TEXT
endThe most_recent_answer_text method asks its
answers association for most_recent answer. It
only wants the text from that answer, but it must first
check to make sure that an answer actually exists to get
text from. It needs to perform this check because
most_recent might return nil:
# app/models/answer.rb
def self.most_recent
order(:created_at).last
endThis call clutters up the method, and returning nil is
contagious: Any method that calls most_recent must also
check for nil. The concept of a missing answer is likely to
come up more than once, as in this example:
# app/models/user.rb
def answer_text_for(question)
question.answers.for_user(self).try(:text) || Answer::MISSING_TEXT
endAgain, for_user might return nil:
# app/models/answer.rb
def self.for_user(user)
joins(:completion).where(completions: { user_id: user.id }).last
endThe User#answer_text_for method duplicates the check for
a missing answer—and worse, it’s repeating the logic of what happens
when you need text without an answer.
We can remove these checks entirely from Question and
User by introducing a null object:
# app/models/question.rb
def most_recent_answer_text
answers.most_recent.text
end# app/models/user.rb
def answer_text_for(question)
question.answers.for_user(self).text
endWe’re now just assuming that Answer class methods will
return something answer-like; specifically, we expect an object that
returns useful text. We can refactor Answer to
handle the nil check:
# app/models/answer.rb
class Answer < ActiveRecord::Base
include ActiveModel::ForbiddenAttributesProtection
belongs_to :completion
belongs_to :question
validates :text, presence: true
def self.for_user(user)
joins(:completion).where(completions: { user_id: user.id }).last ||
NullAnswer.new
end
def self.most_recent
order(:created_at).last || NullAnswer.new
end
endNote that for_user and most_recent return a
NullAnswer if no answer can be found, so these methods will
never return nil. The implementation for
NullAnswer is simple:
# app/models/null_answer.rb
class NullAnswer
def text
'No response'
end
endWe can take things just a little further and remove a bit of duplication with a quick extract method:
# app/models/answer.rb
class Answer < ActiveRecord::Base
include ActiveModel::ForbiddenAttributesProtection
belongs_to :completion
belongs_to :question
validates :text, presence: true
def self.for_user(user)
joins(:completion).where(completions: { user_id: user.id }).last_or_null
end
def self.most_recent
order(:created_at).last_or_null
end
private
def self.last_or_null
last || NullAnswer.new
end
endNow we can easily create Answer class methods that
return a usable answer, no matter what.
Drawbacks
Introducing a null object can remove duplication and clutter. But it can also cause pain and confusion:
- As a developer reading a method like
Question#most_recent_answer_text, you may be confused to find thatmost_recent_answerreturned an instance ofNullAnswerand notAnswer. - It’s possible some methods will need to distinguish between
NullAnswers and realAnswers. This is common in views, when special markup is required to denote missing values. In this case, you’ll need to add explicitpresent?checks and definepresent?to returnfalseon your null object. NullAnswermay eventually need to reimplement large part of theAnswerAPI, leading to potential duplicated code and shotgun surgery, which is largely what we hoped to solve in the first place.
Don’t introduce a null object until you find yourself swatting enough
nil values to grow annoyed. And make sure the removal of
the nil-handling logic outweighs the drawbacks above.
Next Steps
- Look for other
nilchecks of the return values of refactored methods. - Make sure your null object class implements the required methods from the original class.
- Make sure no duplicated code exists between the null object class and the original.
Truthiness,
try and Other Tricks
All checks for nil are a condition, but Ruby provides
many ways to check for nil without using an explicit
if. Watch out for nil conditional checks
disguised behind other syntax. The following are all roughly
equivalent:
# Explicit if with nil?
if user.nil?
nil
else
user.name
end
# Implicit nil check through truthy conditional
if user
user.name
end
# Relies on nil being falsey
user && user.name
# Call to try
user.try(:name)Ruby Science
The canonical reference for writing fantastic Rails applications from authors who have created hundreds.