How RuboCop Led me to Implement the Null Object Pattern
Last week, in an effort to level-up my knowledge of Ruby style conventions, I connected Code Climate and installed the RuboCop gem in a few of the older projects I’ve been refactoring. As I expected with a new install in existing apps, RuboCop presented me with a backlog of roughly 300 items to address in each app.
The most interesting suggestions were in a SessionsController
because I got to learn about the Safe Navigation Operator, or &.
, which led to learning about using the Null Object Pattern.
In the SessionsController
below, you’ll see the first RuboCop suggestion (i.e. the tip of the iceberg):
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
...
def create
user = User.find_by(email: params[:email])
# RUBOCOP says: Style/SafeNavigation: Use SafeNavigation
if user && user.authenticate(params[:password])
# [log the user in]
else
# [re-render the sign-in form and show an error message]
end
end
...
end
So what is this Style/SafeNavigation
suggestion about? The Safe Navigation Operator returns nil
if a method is called on a nil object instead of throwing an error.
In his post, The Safe Navigation Operator (&.) in Ruby, Georgi Mitrev has a nice explanation of the case for it:
Imagine you have an account that has an owner and you want to get the owner’s address. If you want to be safe and not risk a Nil error you would write something like the following:
if account && account.owner && account.owner.address ... end
We can rewrite the previous example using the safe navigation operator:
if account&.owner&.address ... end
Now that’s some pretty nifty shorthand for an otherwise long string of &&
s. So in my case, this is the style change RuboCop is suggesting:
if user&.user.authenticate(params[:password])
...
end
Right? Not quite. When I implemented it, RuboCop said:
Lint/SafeNavigationChain: Do not chain ordinary method call after safe navigation operator.
Okay… thanks for the sweet tip on &.
, but what do you want from me, RuboCop???
Better object-oriented programming, my friends. Better OOP. Are you down with OOP? lol.
The Null Object Pattern Solution
In a pairing session, I was able to see the bigger situation here, which is that my code is asking an object about whether it exists when it could just be handing the nil
value in a predicted way. What if user.authenticate(params[:password])
could just safely run regardless of whether an instance of the User
class exists?
That would be nice. And to make that dream a reality, we need a null user object. As Sandi Metz explains in her Nothing is Something talk, this object will stand in as a legitimate object where we’d otherwise have a nil
. Since we will have built this custom object, we get to tell it how to react to situations that would otherwise result in a nil
value and subsequent error.
Running this in the console, we get:
>> user = User.find_by(email: "email-that-doesn't-exist")
>> user
=> nil
>> user.authenticate(params[:password])
!! #<NoMethodError: undefined method `authenticate' for nil:NilClass>
The Guaranteed User
The controller doesn’t want to manage nil
users. It’s busy and has its own stuff to do. It wants a guaranteed user to work with. The controller doesn’t care by what means this guaranteed user happens, it just needs one in order to move forward.
Instead of creating a user
variable with the potential of equaling nil
because a matching record isn’t found in the database, our user
variable will equal some safe object provided to us by our new GuaranteedUser
class. Then, since we don’t have to safeguard against nil
s, we can safely run the user.authenticate
method.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
...
def create
# Instead of a potentially nil instance of the User
# class, we're guaranteeing an actual object which is
# provided by the GuaranteedUser class
user = GuaranteedUser.find_by(email: params[:email])
# Now that we're not worried about managing nils, we can run
# the authenticate method without other safeguards in place
if user.authenticate(params[:password])
# [log the user in]
else
# [re-render the sign-in form and show an error message]
end
end
...
end
If the user doesn’t actually exist in the users
table, how can we guarantee a user object?
We’re not guaranteed to get a User
object, but we’re guaranteed to get a user-like object, and that’s really all we need. The GuaranteedUser
class searches the users
table and if it finds a matching user, it returns that User
object. If there is no user that matches, it returns a NullUser
object. Either outcome supplies us with the user-like object that we need.
# app/models/guaranteed_user.rb
class GuaranteedUser
def self.find_by(params)
User.find_by(params) || NullUser.new
end
end
# app/models/null_user.rb
class NullUser
end
In the console, we try to find a user via its email address. When we cannot find a match, we get an object instead of a nil value:
>> user = GuaranteedUser.find_by(email: "email-that-doesn't-exist")
>> user
=> #<NullUser:0x00007f88130dbc70>
But how is user.authenticate
safe to run now?
It’s not yet, but after we create an authenticate
method for NullUser
, it will be. We’ll make the method return false
just like the authenticate
method from the User
class would if a user that actually existed didn’t pass authentication. This method will always return false because a NullUser
will never pass authentication.
# app/models/null_user.rb
class NullUser
def authenticate(_)
false
end
end
Running this in the console, we get:
>> user = GuaranteedUser.find_by(email: "email-that-doesn't-exist")
>> user
=> #<NullUser:0x0007fed23436d6>
>> user.authenticate(params[:password])
=> false
The generated NullUser
object fails authentication, the sign-in form is re-rendered in the browser, and the human user sees an error about incorrect credentials.
In the case of a legitimate user (an email that does exist in the users
table) that fails authentication because of the wrong password, in the console we get:
>> user2 = GuaranteedUser.find_by(email: 'sample@gmail.com')
=> #<User id: 2, name: "Actual User", email: "sample@gmail.com", created_at: "2016-01-26 18:05:14", updated_at: "2018-08-20 10:03:44">
>> user2.authenticate('wrong-password')
=> false
The User
object fails authentication, the sign-in form is re-rendered in the browser, and the human user sees an error about incorrect credentials. The key point here is regardless of whether the user
variable is pointed to an instance of User
or NullUser
, the rest of the process flows in the same way.
This approach makes for a much cleaner controller. It also leaves decisions about what defines a GuaranteedUser
and a NullUser
up to their own classes to handle. Since we’ve wrapped the concept of a user-like object in a reliable API called GuaranteedUser
, we can access it confidently throughout our codebase and limit the number of conditions we’d otherwise have to write.
Taking this beyond the SessionsController
I personally haven’t implemented anything beyond what my needs were for this controller yet. However, have done so is making me think about other possible uses for this guaranteed user object. I can think of a couple of projects I’ve worked on where I needed a “guest” user to keep my views from blowing up when no logged-in user was present. Using this pattern, gets me a lot closer to what I wanted to have happen in my views:
# app/models/null_user.rb
class NullUser
...
def name
'Guest'
end
end
<!-- app/views/some_view.html.erb -->
<!-- I'd get to do this: -->
<h1>Hello, #{@user.name}!</h1>
<!-- Instead of this: -->
<% if @user %>
<h1>Hello, #{@user.name}!</h1>
<% else %>
<h1>Hello, Guest!</h1>
<% end %>
Though I still have to work out the rest of the details of making this happen, this Null Object Pattern concept feels like a solid foundation on which to work.
Side Note:
A big chunk of those 300ish RuboCop suggestions belonged to the Style/FrozenStringLiteralComment
category, which is to prepare my Ruby 2.5.0 codebase for upcoming changes in Ruby 3.0. If you find yourself staring down the prospect of adding the magic comment line:
# frozen_string_literal: true
to the top of almost all of the files in your Rails app like I did, don’t worry, there’s a gem for that.