Skip to Content
Course content

93: The Null Object Pattern in Ruby

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably spent a fair amount of time using the safe navigation operator &. or || to stop your app from blowing up with the dreaded NoMethodError: undefined method '...' for nil:NilClass. When you first encounter this, it feels like a victory. You've silenced the error, and your code is "safe."

The Safe Navigation Operator is a Complete Solution

The common misconception is that &. is the "correct" way to handle missing data. It's not. While it prevents a crash, it forces the caller of the method to care about whether an object exists or not. This is what I call "defensive programming," and it quickly turns your business logic into a minefield of conditionals.

Look at this snippet for a hypothetical e-commerce system where a User might or might not have a DiscountProfile:

class Order
  def total(user)
    discount = user.discount_profile&.amount || 0
    100 - discount
  end
end

This looks clean, right? But what happens when you need to check discount_profile.eligible?, discount_profile.expiry_date, and discount_profile.code in five different places across your app? You end up peppering your entire codebase with &. and ||. You aren't describing how your system works; you're spending all your energy managing the fact that something might be missing.

Turning "Nothing" into a First-Class Object

The Null Object Pattern flips the script. Instead of returning nil and making the rest of the app handle the void, we return a specific object that represents "nothing" but still speaks the same language as the "something" object. It's a way of saying, "Yes, this is empty, but here is how an empty version of this object behaves."

Let's refactor that discount example. We'll create a NullDiscountProfile that implements the same methods as a real profile, but returns default, neutral values.

class DiscountProfile
  attr_reader :amount
  def initialize(amount)
    @amount = amount
  end

  def eligible?
    true
  end
end

class NullDiscountProfile
  def amount
    0
  end

  def eligible?
    false
  end
end

class User
  attr_reader :discount_profile

  def initialize(discount_profile = nil)
    # We ensure that we NEVER return nil here
    @discount_profile = discount_profile || NullDiscountProfile.new
  end
end

Now, look at how the Order class changes:

class Order
  def total(user)
    # No safe navigation, no || 0. Just call the method.
    100 - user.discount_profile.amount
  end
end

I love this approach because the Order class no longer needs to know that a user might lack a discount profile. It just asks for the amount and gets a number. The logic for "what happens when there is no discount" is encapsulated inside the NullDiscountProfile class, where it belongs.

A few things to keep in mind as you implement this:

  • Interface Parity: Your Null Object must respond to the same methods as the real object. If the real object has a save method, the Null Object should probably have one too (though it might do nothing).
  • Avoid "Leaking" Nil: The goal is to stop nil from propagating. The best place to swap nil for a Null Object is usually in the getter method or the factory that instantiates the object.
  • Truthiness: Remember that in Ruby, every object except false and nil is truthy. If you have code that does if user.discount_profile, it will now always be true. If you actually need to check for the absence of a real object, you can add a method like null? to both classes.



📋 Practical Task

Refactoring the Guest Customer System

You are working on a checkout system where some customers are registered members and some are guests. Currently, the code is riddled with nil checks because guests don't have a CustomerProfile object.

Your Task: Implement a NullCustomerProfile class to remove the defensive programming from the Invoice class.

class CustomerProfile
  attr_reader :name, :email
  def initialize(name, email)
    @name = name
    @email = email
  end

  def loyalty_points
    100 # Simplified for this example
  end
end

class Invoice
  def summary(profile)
    # TODO: Refactor this method to remove the conditional logic
    if profile
      "Customer: #{profile.name} (#{profile.email}) - Points: #{profile.loyalty_points}"
    else
      "Customer: Guest (N/A) - Points: 0"
    end
  end
end

# Current problematic usage:
invoice = Invoice.new
puts invoice.summary(CustomerProfile.new("Alice", "alice@example.com")) # Works
puts invoice.summary(nil) # Requires the 'if' check in Invoice

Requirements:

  1. Create a NullCustomerProfile class that provides default values for name, email, and loyalty_points.
  2. Modify Invoice#summary so it can simply call the methods on the profile object without using an if/else or &..
  3. Ensure that invoice.summary(NullCustomerProfile.new) produces the same output as the original invoice.summary(nil).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.