Reusable Object-Oriented Systems

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:

  1. it is vulnerable to combinatorial explosion when there are multiple independent parts of the code that vary.
  2. 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.

Alexander M About 7 years ago