SOLID++: Law Of Demeter

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 # ViolationCan 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 fixedIn 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 # ViolationWhen 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 violationIn 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 violationIn 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 violationThe 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.temperatureWhy 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.temperatureWhy 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.greetWhy 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.nameWhy 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
endIn 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.