Introduction
One of the terms that often come in mind while doing code reviews is the famous Law of Demeter. In this blog note, we will explain what does that mean, when is it broken and how to avoid breaking it by making the architecture of application better.
Definition
Law of Demeter is summarised on Wikipedia with three bullet points:
- Each unit should have only limited knowledge about other units: only units “closely” related to the current unit.
- Each unit should only talk to its friends; don’t talk to strangers.
- Only talk to your immediate friends.
In other words - method of one object can call only methods of:
- same object,
- any parameter passed to it,
- object created by it,
- simple type
So… what does that really mean?
Let’s start with simple Sinatra application - it will consist of a controller and a few Ruby objects (with associated tests):
require 'bundler/setup'
require 'sinatra'
require_relative 'example.rb'
get '/:id' do
UserRepository.find(params[:id].to_i).to_json
end
require 'json'
class User
def initialize(email:, address: nil)
@email = email
@address = address
end
# simulating has_one relation
attr_accessor :address
def build_address(*params)
@address = Address.new(*params)
end
def as_json(*)
{
email: email,
city: address.city.canonical_name
}
end
def to_json
as_json.to_json
end
private
attr_reader :email
end
class Address
attr_reader :city
def initialize(city=nil)
@city = city
end
end
class City
def initialize(name)
@name = name
end
def canonical_name
@name
end
end
class UserRepository
DATA = {
0 => User.new(email: '[email protected]', address: Address.new(City.new("Warsaw"))),
1 => User.new(email: '[email protected]')
}
def self.find(id)
DATA[id]
end
def self.first
find(0)
end
def self.all_ids
DATA.keys
end
end
At a first glance everything seems to be working fine:
$ curl http://localhost:4567/0.json
{"email":"[email protected]","city":"Warsaw"}
But, what will happen if we try to request a user without an associated address?
$ curl http://localhost:4567/1.json
NoMethodError: undefined method `city' for nil:NilClass
example.rb:20:in `as_json'
Of course, when you encounter it in this situation - you’re a lucky person. The more realistic scenario, of course, is that one of your clients catch this one on production.
So - let’s ask ourselves one question - why this happened and how we can prevent errors like that from occurring in the future?
That moment when breaking the Law of Demeter is kicking your butt
Let’s look at one line of our serialization:
city: address.city.canonical_name
One of the heuristics you can use when trying to stop breaking LoD is - how many dots there are in an expression? Here we can easily spot two - and this is bull’s-eye. We’re accessing an internal property of another object. We don’t know if this property is set. If this property name ever changes - we need to change our call in utterly unrelated part of the code. Through this, the classes are also violating the Single Responsibility Principle (class should only have one reason to change. Now it got one more - change of the name of the attribute in a separate class).
Let’s write a test covering this case and look at the ways we can refactor our code - to pass this test and abide Law of Demeter.
# example_test.rb
require 'minitest/autorun'
require_relative 'example.rb'
class ExampleTest < Minitest::Test
def test_as_json_all_fields
object = User.new(email: '[email protected]', address: Address.new(City.new("Warsaw")))
assert_equal({email: "[email protected]", city: "Warsaw"}, object.as_json)
end
def test_as_json_without_city
object = User.new(email: '[email protected]', address: Address.new())
assert_equal({email: "[email protected]", city: nil}, object.as_json)
end
def test_as_json_without_address
object = User.new(email: '[email protected]')
assert_equal({email: "[email protected]", city: nil}, object.as_json)
end
end
Obviously, this test is failing right now:
$ ruby example_test.rb
Run options: --seed 17171
# Running:
EE.
Finished in 0.001049s, 2859.8663 runs/s, 953.2888 assertions/s.
1) Error:
ExampleTest#test_as_json_without_address:
NoMethodError: undefined method `city' for nil:NilClass
example.rb:20:in `as_json'
example_test.rb:17:in `test_as_json_without_address'
2) Error:
ExampleTest#test_as_json_without_city:
NoMethodError: undefined method `canonical_name' for nil:NilClass
example.rb:20:in `as_json'
example_test.rb:12:in `test_as_json_without_city'
3 runs, 1 assertions, 0 failures, 2 errors, 0 skips
So, let’s take a look of a way out of this pickle.
First solution: null object
As we’re developing in an object-oriented language, one can start by introducing null objects:
require 'json'
class User
def initialize(email:, address: NullAddress.new)
@email = email
@address = address
end
# …
end
class Address
attr_reader :city
def initialize(city=NullCity.new)
@city = city
end
end
class NullAddress
def city
OpenStruct.new(city: nil, canonical_name: nil)
end
end
class NullCity
def canonical_name
nil
end
end
So - we’re either provide a real object to the constructor, or the fake one substitutes it. Let’s check our test and web app…
$ ruby example_test.rb
Run options: --seed 63275
# Running:
...
Finished in 0.001087s, 2759.8896 runs/s, 2759.8896 assertions/s.
3 runs, 3 assertions, 0 failures, 0 errors, 0 skips
$ curl http://localhost:4567/1.json
{"email":"[email protected]","city":null}
Seems good. However… not really. We’re still breaking LoD! The only thing this code has done is to manage not breaking when the object is accessed. When attribute name change, we would need to change it in an unrelated place. It solves one of our issues. However, it doesn’t change anything about our classes entanglement issues. So - 2/10, would not refactor again.
Second solution: safe traverse operator
Safe navigation operator, introduced in Ruby 2.3, is a way of safely accessing internal properties, so if anything in the chain is nil
, it will return nil
instead of blowing up.
So, the only change would be:
class User
# …
def as_json(*)
{
email: email,
city: address&.city&.canonical_name
}
end
# …
end
tests passes, and web app work:
$ ruby example_test.rb
Run options: --seed 63275
# Running:
...
Finished in 0.001087s, 2759.8896 runs/s, 2759.8896 assertions/s.
3 runs, 3 assertions, 0 failures, 0 errors, 0 skips
$ curl http://localhost:4567/1.json
{"email":"[email protected]","city":null}
However - I would consider this an antipattern. I’ve seen way too many times in my work that many people would mindlessly insert &.
if code blows up because of nil
. Not mentioning that it doesn’t - again - solve tight coupling problem.
Variation of this could use monads - from functional programming (using gem dry-monads):
require 'dry-monads'
class User
# …
def as_json(*)
{
email: email,
city: Dry::Monads::Maybe(address).fmap(&:city).fmap(&:canonical_name).value_or(nil)
}
end
# …
end
Unfortunately - same story as before. Classes entanglement is the same, sorry.
Third solution: active support delegation
How about delegation? That sounds like a good solution.
require 'json'
require 'active_support/core_ext/module/delegation'
class User
def initialize(email:, address: nil)
@email = email
@address = address
end
# simulating has_one relation
attr_accessor :address
def build_address(*params)
@address = Address.new(*params)
end
def as_json(*)
{
email: email,
city: city.canonical_name
}
end
def to_json
as_json.to_json
end
private
attr_reader :email
delegate :city, to: :address, allow_nil: true
end
class Address
attr_reader :city
def initialize(city=nil)
@city = city
end
delegate :name, to: :city, allow_nil: true
delegate :canonical_name, to: :city, allow_nil: true
end
It, unfortunately, doesn’t solve our issue:
$ ruby example_test.rb
Run options: --seed 18283
# Running:
.E
Error:
ExampleTest#test_as_json_without_address:
NoMethodError: undefined method `city' for nil:NilClass
example.rb:21:in `as_json'
example_test.rb:17:in `test_as_json_without_address'
bin/rails test example_test.rb:15
E
Error:
ExampleTest#test_as_json_without_city:
NoMethodError: undefined method `canonical_name' for nil:NilClass
example.rb:21:in `as_json'
example_test.rb:12:in `test_as_json_without_city'
It catches the first nil
- but we’re trying to call the second method on it. Aaaand it’s broken.
Of course, this code still has entanglement issues, and it requires active_support
- which is a huge library and it not needed here. So we still don’t have a solution.
Fourth and final: better architecture
Previous steps should teach you one thing - there is no such thing as a silver bullet. Inserting &.
randomly into your code doesn’t make it better. If you’re violating LoD, you will be still violating it. If there is no issue with nil
right now, there will be an issue with changing attribute name later. But, there is a way. So, let’s refactor our code into several files.
First, we will need null objects:
# null_objects.rb
class NullCity
def name
nil
end
def canonical_name
nil
end
end
class NullAddress
def city
NullCity.new
end
end
However, we will be using them together with other architecture related stuff. Not on their own.
Then, let’s move to serializers - this is where json
generation should take place.
# serializers.rb
class AddressSerializer
attr_reader :address
def initialize(address)
@address = address
end
def city
address.city || NullCity.new
end
def canonical_name
city.canonical_name
end
end
class UserSerializer
attr_reader :user
def initialize(user)
@user = user
end
def address
user.address || NullAddress.new
end
def as_json(*)
{
email: user.email,
city: AddressSerializer.new(address).canonical_name
}
end
def to_json
as_json.to_json
end
end
Great, we’re now handling all of our edge cases with missing attributes.
Of course, it needed to be reflected both in our main application and in tests:
# app.rb
# …
get '/:id' do
UserSerializer.new(UserRepository.find(params[:id].to_i)).to_json
end
We will need some factories inside the repository and tests. We do not want to jsonify naked objects:
class UserFactory
def self.create_user(email:, city_name: nil)
address = nil
if city_name
city = City.new(city_name)
address = Address.new(city)
end
User.new(email: email, address: address)
end
end
and wrapping it all in main app:
require 'json'
require_relative 'null_objects.rb'
require_relative 'serializers.rb'
require_relative 'factories.rb'
class User
attr_reader :email
def initialize(email:, address: nil)
@email = email
@address = address
end
# simulating has_one relation
attr_accessor :address
def build_address(*params)
@address = Address.new(*params)
end
end
class Address
attr_reader :city
def initialize(city=nil)
@city = city
end
end
class City
def initialize(name)
@name = name
end
def canonical_name
@name
end
end
class UserRepository
DATA = {
0 => UserFactory.create_user(email: '[email protected]', city_name: "Warsaw"),
1 => UserFactory.create_user(email: '[email protected]')
}
def self.find(id)
DATA[id]
end
def self.first
find(0)
end
def self.all_ids
DATA.keys
end
end
Take a look at how thin User
became. It doesn’t know anything about the internal structure of address right now. And it should stay that way. We’ve removed any traces of serialization from both User
and Address
.
So, finally, it’s time to swap this factory into test and use serializer there:
require 'minitest/autorun'
require_relative 'example.rb'
class ExampleTest < Minitest::Test
def test_as_json_all_fields
object = UserFactory.create_user(email: '[email protected]', city_name: "Warsaw")
assert_equal({email: "[email protected]", city: "Warsaw"}, UserSerializer.new(object).as_json)
end
def test_as_json_without_city
object = UserFactory.create_user(email: '[email protected]')
assert_equal({email: "[email protected]", city: nil}, UserSerializer.new(object).as_json)
end
def test_as_json_without_address
object = UserFactory.create_user(email: '[email protected]', city_name: nil)
assert_equal({email: "[email protected]", city: nil}, UserSerializer.new(object).as_json)
end
end
Tests are green:
$ ruby example_test.rb
Run options: --seed 30472
# Running:
...
Finished in 0.001344s, 2232.1429 runs/s, 2232.1429 assertions/s.
3 runs, 3 assertions, 0 failures, 0 errors, 0 skips
and web app works:
$ curl http://localhost:4567/1.json
{"email":"[email protected]","city":null}
Also - take a look that now, when canonical_name
attribute name will change, the only place we will need to change is AddressSerializer
. Any other classes don’t know about the internal structure of another object.
Conclusion
We looked at Law of Demeter in this blog note. We saw some code that was breaking it. We introduced a few techniques to mitigate issues with it - however, most of them were only a band-aid, not removing the real cause.
The real cause was breaking the law of Demeter, which we finally fixed and obtained a code with much less class entanglement and clean code.