A Null Object is a drop in replacement for one of the other objects in your
system that provides sensible defaults when the other object is unavailable. For
example, I recently wrote about returning the path to a blank partial as a
sensible default for to_partial_path
. Null Objects remove respond_to?
and nil?
checks, and make code cleaner and easier to understand. It almost
seems like there’s no downside. Almost.
Too good to be true
As with everything else in software development, there is a trade-off involved
in introducing a Null Object: Whenever we change the public interface of the real
object, we have to make a corresponding change in the Null Object that shadows
it. If the interfaces diverge, then the Null Object ceases to be useful; instead
of hiding complexity, it hides the potential for unexpected NoMethodError
exceptions. I might even go so far as to say that Null Objects usually smell a
little bit like Shotgun Surgery.
Unit tests to the rescue
Our Null Objects are only doing their job when they have the same public interface as another object, so we should treat this like any other expectation we have about the public interface of one of our objects and ensure it with a unit test.
To ensure this requirement I recently added a test that looked something like this:
describe NullGraph do
it 'exposes the same public interface as Graph' do
expect(described_class).to match_the_interface_of Graph
end
end
When the public interface of NullGraph
includes all of the methods from
Graph
‘s public interface then the test passes. When the interfaces differ, the
test fails with a helpful message telling me which methods are missing from
NullGraph
. When I see this test fail, I can add another test with an
expectation of what the Null Object’s implementation of the missing method
should return.
This is all made possible by a custom RSpec matcher:
RSpec::Matchers.define :match_the_interface_of do
match do
missing_methods.empty?
end
failure_message_for_should do
"expected #{actual.name} to respond to #{missing_methods.join(', ')}"
end
def missing_methods
expected_methods - actual_methods - common_methods
end
def expected_methods
expected.map(&:instance_methods).flatten
end
def actual_methods
actual.instance_methods
end
def common_methods
Object.instance_methods
end
end
The big picture
I’ve found this technique and this matcher to be useful, and I hope you do too. There’s a more important principle at work here though: Everything is a trade-off, even good (or trendy) solutions add complexity. We should always be asking ourselves what form that complexity takes, so that we can understand it and mitigate its effects on our ability to change the code in future.
When it comes to thinking about the trade-offs involved in your design decisions, I can heartily recommend POODR by Sandi Metz, and thoughtbot’s own Ruby Science. Both books have helped me see these issues more clearly.
What’s next
If you found this useful, you might also enjoy: