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.