If you can’t fix it with duct tape, you haven’t used enough
~ American folk saying
In a previous post, we looked at building an API adapter for fetching
directors from a service called movie-facts.com
. It comes in two flavors,
read from HTTP and read from cache. The shared logic between the two of these is
extracted to a base class.
Paginated requests
After a few weeks in production, you get a bug report. The directors page only
displays 10 directors even though the movie-facts.com
API contains many more.
You try to duplicate the problem locally and quickly realize that the
movie-facts.com
API is paginated, 10 per page. Unfortunately, they seem to
hard code that 10 results per page. You’ll need to make multiple requests and
combine the results together.
In the previous example, following the principle
Separate things that change from those that stay the same
led us to using inheritance in a way that allowed us to easily extend and change the system in the future.
However, we now have two independent ways for logic to vary. There are (2 ways to fetch data) X (2 ways of de-paginating data) = 4 combinations. These are:
DepaginatedHttpClient < ClientBase
PaginatedHttpClient < ClientBase
DepaginatedCacheClient < ClientBase
PaginatedCacheClient < ClientBase
Our inheritance hierarchy now looks like:
The logic is duplicated and reused in many places.
We could try to get clever and add another layer of inheritance to remove some of the duplication:
HttpClient < ClientBase
DepaginatedHttpClient < HttpClient
CacheClient < ClientBase
DepaginatedCacheClient < CacheClient
There is still duplication of the de-pagination logic and this solution won’t scale once we add more fetching classes or a third independent thing that can change. We are going to need something more powerful than simple inheritance.
Modules
Ruby implements multiple inheritance via modules (often referred to as “mixins”). We can define a module with our de-pagination logic:
module Depaginatable
def fetch_depaginated_data
# make multiple calls to `fetch_data`
# combine results together
end
end
Now the child classes look like:
module MovieFacts
class HttpClient < ClientBase
include Depaginatable
private
def fetch_data
# make HTTP request
# cache response
end
end
end
and
module MovieFacts
class CacheClient < ClientBase
include Depaginatable
private
def fetch_data
# read data from cache
end
end
end
We’ve now eliminated all duplication and can now extend our system without too much effort. Success!
Rails uses this approach heavily, taking advantage of modules and concerns (a specialized type of module provided by Rails) to add multiple inheritance throughout the its source.
Limitations of multiple inheritance
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).
Like simple inheritance, multiple inheritance suffers from encapsulation issues. Although we’ve used some clever tricks to prevent duplicating code, we’ve still duplicated logic across our clients.
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. Because a sub-class is all of its ancestors combined, an
HttpClient
instance looks more like this:
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.
In the next article, we address these concerns by looking at composition as an alternate implementation approach.
Further Reading
This article is part 2 of 4 in a series on building reusable object-oriented software.