Posted almost 5 years ago. Visible to the public.

Reusable Object-Oriented Systems

Simple Inheritance

Copy
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”).

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

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

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

Owner of this card:

Avatar
Alexander M
Last edit:
almost 5 years ago
by Alexander M
Tags:
Software-Architecture
Posted by Alexander M to Ruby and RoR knowledge base
This website uses short-lived cookies to improve usability.
Accept or learn more