Simple Inheritance
require 'json'
module MovieFacts
class Director
def initialize(json)
@raw_data = JSON.parse(json)
end
def name
@raw_data.fetch('name')
end
def id
@raw_data.fetch('id')
end
end
class ClientBase
NotImplementedError = Class.new(StandardError)
def initialize(client_id, client_secret)
@client_id = client_id
@client_secret = client_secret
end
def directors
fetch_data('/directors').map { |director| Director.new(director) }
end
def director(name)
Director.new fetch_data("/directors/#{name}")
end
private
def fetch_data(_)
raise NotImplementedError
end
end
class HttpClient < ClientBase
private
def fetch_data(_)
puts 'Making HTTP request...'
puts 'Response cached'
end
end
class CacheClient < ClientBase
private
def fetch_data(_)
puts 'Read data from cache'
end
end
end
MovieFacts::HttpClient.new(123, 'some-key-123').director('Noname')
Simple inheritance does have a few critical weaknesses when it comes to building reusable OO components:
- it is vulnerable to combinatorial explosion when there are multiple independent parts of the code that vary.
- there is no encapsulation between parents and descendants.
Mixins/Multiple Inheritance
Ruby implements multiple inheritance via modules (often referred to as “mixins”).
module Depaginatable
def fetch_depaginated_data
puts 'Makes multiple calls to `fetch_data` and combine results together'
end
end
module MovieFacts
class HttpClient < ClientBase
include Depaginatable
private
def fetch_data(_)
puts 'Making HTTP request...'
puts 'Response cached'
{id: nil, name: ''}.to_json
end
end
class CacheClient < ClientBase
include Depaginatable
private
def fetch_data(_)
puts 'Read data from cache'
{id: nil, name: ''}.to_json
end
end
end
MovieFacts::CacheClient.new(123, 'some-key-123').fetch_depaginated_data
Multiple inheritance solved our combinatorial explosion problem. Mostly. Note that the method in our module is fetch_depaginated_data
and now fetch_data
. We needed to be able to make both normal and de-paginated requests. Code that uses one of our clients needs to know about the difference and make a decision about which one it wants to use. We can’t fully leverage polymorphism and create an object that responds to fetch_data
, and would return the correct (raw/de-paginated) from (http/cache).
We are using a single object to do all the things. An HttpClient
instance knows how to fetch data, de-paginate it, and convert it into Director
instances. There are no clearly defined responsibilities. Each ancestor has access to the private methods and instance state of the combined object. Since there are no boundaries, it’s easy to refactor the implementation of a function in an ancestor in a way that doesn’t change its behavior and yet still break one of the descendent classes.
Composition
Let’s take a completely different approach to solving the problem. Instead of building up a single objects that does all of the things via inheritance, we are going to cut it up into several smaller object focused on a single responsibility and combine them together via composition.
require 'json'
module MovieFacts
class Director
def initialize(json)
@raw_data = JSON.parse(json)
end
def name
@raw_data.fetch('name')
end
def id
@raw_data.fetch('id')
end
end
class Client
def initialize(driver, depaginator)
@driver = driver
@depaginator = depaginator
end
def directors
fetch_data('/directors').map { |director| Director.new(director) }
end
def director(name)
Director.new fetch_data("/directors/#{name}")
end
private
def fetch_data(path)
@depaginator.depaginate @driver.fetch_data(path)
end
end
class HttpDriver
def fetch_data(path)
puts "Making HTTP request to #{path}"
puts 'Response cached'
end
end
class CacheDriver
def fetch_data(path)
puts 'Read data from cache'
end
end
class Depaginator
def depaginate(data)
puts "Depaginates the #{data}"
end
end
class NoopDepaginator
def depaginate(data)
data # just return the data without de-paginating it
end
end
end
MovieFacts::Client.new(MovieFacts::HttpDriver.new, MovieFacts::NoopDepaginator.new).director('Noname')
MovieFacts::Client.new(MovieFacts::CacheDriver.new, MovieFacts::Depaginator.new).director('Noname')
This is completely modular and can be extended in any way we wish. There is no fear of combinatorial explosion here. Each responsibility is nicely encapsulated in its own object which means it can be refactored any time without affecting any of the collaborating objects.
A composition-based approach allows us to build smaller, self-contained objects that respect encapsulation, and can be endlessly combined with each other to solve the combinatorial explosion problem. Composition’s main strength, combining small objects, is also its greatest weakness. Combining many small objects can be a lot of work. Taken to an extreme, you need to create dedicated factory objects just to figure out which objects to combine with each other. Although you’ve made each individual object easier to understand because it stands on its own and is small, inderection is increased in the system overall as you try to follow the execution path of a method from one collaborator to the next.
Decorators
Decoration allows you to layer on functionality, building up an object that responds to methods provided the inner object and all of the decorators. Because any decorator can be layered on top of any other decorator we are safe from combinatorial explosion. We also have encapsulation between each of the layers since they can only communicate to each other via each other’s public interface.
The client now just takes in an object that responds to fetch_data
. That could be a driver or a driver that’s been decorated with the depaginator. The drivers would stay the same as before. The depaginator is turned into a decorator. We no longer need a NoopPaginator
because we can get the same effect by passing in an undecorated driver.
require 'json'
module MovieFacts
class Director
def initialize(json)
@raw_data = JSON.parse(json)
end
def name
@raw_data.fetch('name')
end
def id
@raw_data.fetch('id')
end
end
class Client
def initialize(driver)
@driver = driver # may or may not be decorated with a depaginator
end
def directors
@driver.fetch_data('/directors').map { |director| Director.new(director) }
end
def director(name)
Director.new(@driver.fetch_data("/directors/#{name}"))
end
end
class HttpDriver
def fetch_data(path)
puts "Making HTTP request to #{path}"
puts 'Response cached'
{id: nil, name: ''}.to_json
end
end
class CacheDriver
def fetch_data(path)
puts 'Read data from cache'
{id: nil, name: ''}.to_json
end
end
class Depaginator < SimpleDelegator
def fetch_data(path)
data = __getobj__.fetch_data(path)
puts "Depaginates the #{data}"
data
end
end
end
MovieFacts::Client.new(MovieFacts::HttpDriver.new).director('Noname')
MovieFacts::Client.new(MovieFacts::Depaginator.new(MovieFacts::CacheDriver.new)).director('Noname')
Conclusion
In general, composition is a more robust and flexible approach to architect reusable object-oriented software. Composition should be your go-to solution most of the time. Only reach for inheritance when it makes sense or when you don’t have a choice such as when building ActiveRecord
models.