The Null Object Pattern and Ruby

by Jared Norman
published January 24, 2024

I’m a proponent of borrowing object-oriented programming patterns and techniques from other languages and applying them to Ruby. I make frequent use of the factory pattern, and my TDD practice is cribbed primarily from texts written in Java.

I’ve always liked the null object pattern. The pattern involves creating an object with a null or neutral implementation of an established interface. This null object is used to represent the absence of a given object so that consuming code doesn’t need to handle that case explicitly. It is one way to avoid null propagation.

I work in eCommerce, so I’ll draw an example from that. Suppose your system receives a notification from the warehouse that a shipment has been shipped. You need to mark it as shipped in your system and save the tracking info.

def process_shipping_notification(notification)
  shipment = Shipment.for_number(notification.shipment_number)

  shipment.mark_shipped(tracking_code: notification.tracking_code)
end

This method accepts the notification, using the shipment number to look up the shipment, then updates it.

Unfortunately, when you deploy this, it starts generating errors. It turns out that the warehouse sends notifications for shipments not sent from your system, as the warehouse also processes shipments from another source and can’t differentiate them.

One solution is to add some error handling.

def process_shipping_notification(notification)
  shipment = Shipment.for_number(notification.shipment_number)

  return unless shipment

  shipment.mark_shipped(tracking_code: notification.tracking_code)
end

For something this simple, an early return is the right solution, but there is an alternative: the null object pattern. You could implement a new class, NullShipment, that has the same methods as our Shipment class but those methods don’t do anything. When Shipment.for_number fails to find the shipment, it can returning an instance of NullShipment instead of nil.

class NullShipment
  def mark_shipped(tracking_code:)
  end
end

Now our original code doesn’t need to change. It doesn’t need to know whether the shipment was found. Our NullShipment could even provide observability, logging that our system received information about a shipment that wasn’t in the system.

Like I said, handling a single case where the object can be nil is probably not worth introducing this pattern. The benefit is with more complex objects that are used in more places. Rather than updating all of the different places to handle the absence of the shipment, the null pattern allows you to collect the logic about how the absence of a shipment is handled in a single class.

This pattern is most useful for object’s whose methods represent commands. You can use the pattern when the object’s represent queries (they return values), but only if there are coherent return for your null implementation.

Often the design can be adjusted to work around this constraint. It can push you towards better command-query separation, improving your design just by fitting the pattern into it.

Unfortunately, in Ruby and other dynamically typed languages, the null object pattern has an increased maintenance cost. In statically typed languages, both the “normal” and the null implementation of a given object can implement the same interface. The language’s type system will ensure that if you add methods to the interface that both classes must have implementations.

Ruby has no such mechanism. It’s easy to add a method to the “normal” implementation and forget to add it to the null version. It’s possible to write tests to work around this, but this is a significant downside to using the pattern and why I don’t use it more often.

If you find that code to handle the absence of a particular object is spread all over your codebase, consider the null object pattern. Group all the nil-handling code into a single class and return it in place of nil when dealing with that object’s absence. It will simplify your system, group related logic, and push your towards a better design.