Contents

SOLID++: Law Of Demeter

Nosey Bull

Introduction: getting to know Demeter

Have you ever looked at your code and thought ‘Why does this class need to know about that other class’s internal workings?’ Such dependencies can make code difficult to understand, maintain, and test. The Law of Demeter, also known as the principle of least knowledge, offers a way out of this complexity. Let’s explore how this principle can help you write cleaner, more maintainable code.

🔍 This post is part of the SOLID++ series where we are exploring principles of object-oriented design beyond the five SOLID principles.

What is the Law of Demeter?

The Law of Demeter suggests that a given object should only interact with its immediate collaborators and not reach through them to interact with other objects’ methods or data. This helps reduce coupling and increase modularity in code. Think of how you interact with a restaurant - you talk to the waiter, not the chef directly. The Law of Demeter works similarly in code.

Why follow Demeter?

Following Demeter’s law offers several benefits:

  • It makes your code more modular and easier to understand
  • It reduces coupling between objects
  • It simplifies refactoring and maintenance

Now let’s roll up our sleeves and look at some code that needs help.

🔍 In this post, we’ll use Ruby 3.1 for code examples with endless methods for brevity, but the principles of Demeter apply to any object-oriented language.

Common violations: spotting the problem

Let’s look at an example of a Demeter violation:

class Engine
  def start = 'Engine started!'
end

class Car
  attr_reader :engine

  def initialize(engine) = (@engine = engine)
end

car = Car.new(Engine.new)
car.engine.start  # Violation

Can you spot the violation here? The violation is that the Car class is directly accessing the Engine object’s start method. This breaks Demeter’s law by reaching too far into the Engine object.

Common violations: fixing the problem

Let’s fix this violation by introducing a method in the Car class to start the engine:

class Engine
  def start = 'Engine started!'
end

class Car
  def initialize(engine) = (@engine = engine)
  def start_engine = engine.start
end

# Usage
car = Car.new(Engine.new)
car.start_engine  # Violation fixed

In this refactored version, we’ve introduced a start_engine method in the Car class that delegates the call to the Engine object. This way, we avoid violating Demeter’s law.

Another example: spotting the problem

class Project
  attr_reader :manager

  def initialize(manager) = (@manager = manager)
end

class Manager
  attr_reader :team

  def initialize(team) = (@team = team)
end

class Team
  def lead = "Alice"
end

project = Project.new(Manager.new(Team.new))
puts project.manager.team.lead  # Violation

When you are reviewing code, look for long chains of method calls like this (aka, “dot chains”). They are often the code smell for Demeter violations. In this example, the Project class is directly accessing the lead method of the Team object through the Manager object. This is another violation of Demeter’s law.

Another example: fixing the problem

class Project
  def initialize(manager) = (@manager = manager)
  def team_lead = @manager.team_lead
end

class Manager
  def initialize(team) = (@team = team)
  def team_lead = @team.lead
end

class Team
  def lead = "Alice"
end

project = Project.new(Manager.new(Team.new))
puts project.team_lead  # No violation

In this corrected version, we’ve introduced a team_lead method in the Project class that delegates the call to the Team object through the Manager object. This way, we avoid violating Demeter’s law by better encapsulating the behavior.

Using Ruby’s built-in tools

In the previous examples, we could have used Ruby’s forwardable module to delegate the method call. This is a common pattern in Ruby to avoid Demeter violations. Let’s refactor the previous example using forwardable:

require 'forwardable'

class Project
  extend Forwardable

  def_delegator :@manager, :team_lead, :team_lead

  def initialize(manager) = (@manager = manager)
end

class Manager
  extend Forwardable

  def_delegator :@team, :lead, :team_lead

  def initialize(team) = (@team = team)
end

class Team
  def lead = "Alice"
end

# Usage
project = Project.new(Manager.new(Team.new))
puts project.team_lead  # No violation

In this refactored version, we’ve used Ruby’s forwardable module to delegate the team_lead method call from the Project class to the Team object through the Manager object. This is a clean and concise way to avoid Demeter violations.

Demeter on Rails: the delegate method

Rails ActiveSupport provides a bit of syntactic sugar to make following Demeter even easier. Let’s use the delegate method to refactor the previous example:

class Project
  delegate :team_lead, to: :@manager

  def initialize(manager) = (@manager = manager)
end

class Manager
  delegate :team_lead, to: :@team

  def initialize(team) = (@team = team)
end

class Team
  def team_lead = "Alice"
end

# Usage
project = Project.new(Manager.new(Team.new))
puts project.team_lead  # No violation

The delegate method allows us to easily forward method calls to associated objects, keeping our Rails code clean and readable.

Exceptions to the rule

While Demeter is generally a good practice, there are situations where you might need to bend the rules:

When working with external APIs or libraries

class WeatherService
  def temperature = WeatherAPI.get_temperature
end

class WeatherAPI
  def self.get_temperature = 25
end

service = WeatherService.new
puts service.temperature

Why does this not violate Demeter? Because the WeatherService class is directly accessing the get_temperature method of the WeatherAPI class. In this case, it might be acceptable due to the nature of the external API.

When working with data structures like hashes or arrays

class WeatherService
  def temperature = WeatherAPI.weather_data[:temperature]
end

class WeatherAPI
  def self.weather_data = { temperature: 25 }
end

service = WeatherService.new
puts service.temperature

Why does this not violate Demeter? Because the WeatherService class is directly accessing the temperature attribute of the weather_data hash. In this case, it might be acceptable due to the nature of the data structure.

In cases of immutability

class User
  attr_reader :name

  def initialize(name) = (@name = name)
end

class Greeting
  def initialize(user) = (@user = user)

  def greet = "Hello, #{user.name}!"
end

user = User.new("Alice")
greeting = Greeting.new(user)
puts greeting.greet

Why does this not violate Demeter? Because the Greeting class is directly accessing the name attribute of the User object. In this case, it might be acceptable due to the immutability of the User object.

With certain design patterns like Builder

class User
  attr_reader :name

  def initialize(name) = (@name = name)
end

class UserBuilder
  attr_reader :user

  def initialize = (@user = User.new)

  def with_name(name)
    user.name = name
    self
  end

  def build = user
end

builder = UserBuilder.new
user = builder.with_name("Alice").build
puts user.name

Why does this violate Demeter? Because the UserBuilder class is directly accessing the name attribute of the User object. In this case, it might be acceptable due to the nature of the Builder pattern. That is, the UserBuilder class is responsible for constructing the User object.

Testing benefits

Following Demeter’s law can make your code easier to test. By reducing the coupling between objects, you can write more focused unit tests that don’t need to know about the internal details of other objects. This makes your tests more resilient to changes and easier to maintain. For example:

class Engine
  def start = 'Engine started!'
end

class Car
  def initialize(engine) = (@engine = engine)
  def start_engine = engine.start
end

# Test
RSpec.describe Car do
  it 'starts the engine' do
    engine = instance_double(Engine, start: 'Engine started!')
    car = Car.new(engine)
    expect(car.start_engine).to eq('Engine started!')
  end
end

In this test, we’re able to mock the Engine object and focus on testing the Car class in isolation. This is possible because the Car class follows Demeter’s law and doesn’t reach into the Engine object.

Common pitfalls: when to be cautious

While Demeter is a powerful principle, there are some common pitfalls to be aware of:

  • Overuse of delegation: Be cautious of overusing delegation, as it can lead to excessive method chaining and complexity.
  • Excessive abstraction: Avoid creating unnecessary layers of abstraction just to follow Demeter. Sometimes direct access is more readable and maintainable.
  • Performance concerns: Excessive delegation can lead to performance issues due to the overhead of method calls.
  • Contextual understanding: Sometimes direct access to an object’s methods can provide better context and understanding of the code.
  • Code readability: While Demeter aims to improve code readability, excessive delegation can sometimes have the opposite effect.

To avoid these pitfalls, use your best judgment and consider the specific requirements of your codebase. While being careful not to overcomplicate things in the name of following Demeter, also don’t use these pitfalls as an excuse to ignore the principle altogether.

Conclusion: Putting Demeter into Practice

As you start applying the Law of Demeter, you’ll notice your code becoming more modular and easier to understand. Changes that once felt risky become straightforward, and refactoring becomes less of a headache.

Remember, Demeter is not a strict rule but a guideline to help you write cleaner, more maintainable code. Use it wisely, and your code will thank you.