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 # 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.