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:

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.

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.001087 s , 2759.8896 runs / s , 2759.8896 assertions / s . 3 runs , 3 assertions , 0 failures , 0 errors , 0 skips

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.001087 s , 2759.8896 runs / s , 2759.8896 assertions / s . 3 runs , 3 assertions , 0 failures , 0 errors , 0 skips

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:

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:

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.