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
.most_recent.try(:text) || Answer::MISSING_TEXT
answersend
The 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
:created_at).last
order(end
This 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)
.answers.for_user(self).try(:text) || Answer::MISSING_TEXT
questionend
Again, for_user
might return nil
:
# app/models/answer.rb
def self.for_user(user)
:completion).where(completions: { user_id: user.id }).last
joins(end
The 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
.most_recent.text
answersend
# app/models/user.rb
def answer_text_for(question)
.answers.for_user(self).text
questionend
We’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
:completion
belongs_to :question
belongs_to
:text, presence: true
validates
def self.for_user(user)
:completion).where(completions: { user_id: user.id }).last ||
joins(NullAnswer.new
end
def self.most_recent
:created_at).last || NullAnswer.new
order(end
end
Note 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
end
We 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
:completion
belongs_to :question
belongs_to
:text, presence: true
validates
def self.for_user(user)
:completion).where(completions: { user_id: user.id }).last_or_null
joins(end
def self.most_recent
:created_at).last_or_null
order(end
private
def self.last_or_null
|| NullAnswer.new
last end
end
Now 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_answer
returned an instance ofNullAnswer
and notAnswer
. - It’s possible some methods will need to distinguish between
NullAnswer
s and realAnswer
s. 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 returnfalse
on your null object. NullAnswer
may eventually need to reimplement large part of theAnswer
API, 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
nil
checks 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
.name
userend
# Implicit nil check through truthy conditional
if user
.name
userend
# Relies on nil being falsey
&& user.name
user
# Call to try
.try(:name) user
Ruby Science
The canonical reference for writing fantastic Rails applications from authors who have created hundreds.