Object-oriented programming helps organize your code in a resilient, easy-to-change way. This article aims to explore one of the concepts that trips up beginner and more experienced object-oriented programmers: how to sensibly connect a set of objects together to perform a complex task. How do you put instances of your information-hiding, single-responsibility-discharging, message-passing classes in touch with one another?
I became confused about the smartest ways to do this when I started building Ruby apps that involved fetch large amounts of data from external services. In these projects, a Rails or Sinatra web application acted as a facade for workers querying a large set of APIs. Each API was different from the last, requiring different approaches and different dependencies. Some APIs involved five or six different steps, and in some cases each step needed to be handled by a different object.
I felt I understand object-oriented programming pretty well, yet I struggled with specifying the relationships between objects so that each object knew just enough about its peers to get the job done. My style was inconsistent. Sometimes I would inject a dependency using the constructor, and sometimes I would use a setter method. At other times it seemed more natural to have an object directly instantiate new instances of whatever objects it needed, on the fly.
All of the code examples referenced below can be found in this gist.
Object Peer Stereotypes
All of this changed when someone turned me onto the book Growing Object Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce. The book has a chapter on object-oriented design styles, and includes a description of “Object Peer Stereotypes” that addressed my conundrum perfectly.
The authors divide an object’s peers into three categories: Dependencies, Notifications, and Adjustments (DNA). These are rough categories, because an object peer could fit into more than one category, but I found it to be a useful distinction. We’ll explore each of these categories as they pertain to Ruby code using an example from my real production code: a wrapper for Typhoeus I wrote called HttpRequest.
By the way, Gregory wrote about a related topic (what types of arguments to pass into a method) back in Issue 2.14. As your objects become more sophisticated you’ll find you end up passing fewer basic object types like strings, symbols, or numbers, and more of the Argument, Selector, or Curried objects that Gregory describes.
“Services that the object requires from its peers so it can perform its responsibilities. The object cannot function without these services. It should not be possible to create the object without them.”
“...we insist on dependencies being passed in to the constructor, but notifications and adjustment can be set to defaults and reconfigured later.”
I wrote the HttpRequest class so that I could set on_success and on_failure callbacks (where Typhoeus only provides an on_complete callback) and to encapsulate my dependency on the Typhoeus gem, in case I want to switch to another HTTP library later.
HttpRequest objects have two Dependencies: the URL of the request and a set of options for telling Typhoeus how to make the request. Here's a link to an example of HttpRequest code.
If options was a more complex object, something that might have peers of its own, I would probably treat it as an Adjustment. I find test-driven development really helpful in cases like this because often the tests can help you feel out which approach is more appropriate (which is the whole premise of the Growing Object Oriented Software book).
“Peers that need to be kept up to date with the object’s activity. The object will notify interested peers whenever it changes state or performs a significant action. Notifications are ‘fire and forget’; the object neither knows nor cares which peers are listening.”
In the HttpRequest example, success_callbacks and failure_callbacks are the notifications. Another object can register for success notifications like this.
Logging is another canonical notification example. Here’s a pattern I use a lot for logging.
Notifications can also be sent as arguments to a method call. I often pass a block for error handling. I find this usually involves fewer lines of code than returning a status object that must be tested for success or failure. Here's an example.
“Peers that adjust the object’s behavior to the wider needs of the system. This includes policy objects that make decisions on the object’s behalf...and component parts of the object if it’s a composite.”
Most of my Adjustments involve component parts of a composite object. For the API-intense project where I’m using HttpRequest, I always have one class that has overall responsibility for getting all of the data we need for each API. That “master” class just does one thing: it coordinates the activities of a set of Adjustment peers, are of which are set to sensible defaults.
This also enables simple unit testing because you can so easily set the adjustments to mock objects provided by the tests.
If you use the strategy pattern, where peer objects make decisions for your object, your Adjustment might look like this.
It could be that AdminChecker is more of a Dependency than an Adjustment, depending on how many different kinds of AdminCheckers there are and how central admin-checking is to your code. If there’s no normal default for the admin_checker value, and if you really can’t make a DataFetcher without knowing what kind of checking policies it’ll be working with, you should probably inject your admin_checker in the constructor, marking it as an important Dependency.
There’s one other facet to my HttpRequest object that I thought Practicing Ruby readers might find interesting. Because Typhoeus is concurrent, you have to queue up requests onto a shared Typhoeus::Hydra object. The requests don’t run until you invoke the hydra’s #run method. I experimented with storing the Hydra object in various places and ended up creating a factory for HttpRequest objects called HttpRequestService, below. Can you spot the dependencies and adjustments? It doesn’t have notifications, but I could see adding some instrumentation to measure HttpRequest times.
Instances of the HttpRequestService end up as Adjustment peers for the objects responsible for fetching data.
Dependency Injection Containers
Rubyists generally eschew dependency injection containers but they complement the DNA style quite well. I use dependency injection containers as the single place where my code can pull in dependencies from different sources. These dependencies sometimes involve extra setup steps or massaging, depending on whether the code is running in production mode or not, and the container is a convenient place to consolidate that kind of housekeeping code. It often provides the sensible default for notifications and adjustments, and it’s an important part of the boot process for most of my Ruby code.
I’ve created a simple gem for this purpose called dim based on Jim Weirich’s article. If you’re interested in the topic, I highly recommend that article. Here’s a snippet of one of my container definitions.
Don’t hold too rigidly to these classifications; they’re more like heuristics. As Steve Freeman and Nat Pryce wrote:
“What matters most is the context in which the collaborating objects are used. For example, in one application an auditing log could be a dependency, because auditing is a legal requirement for the business and no object should be created without an audit trail. Elsewhere, it could be a notification, because auditing is a user choice and objects will function perfectly well without it.”
When considering how to organize object peers I recommend you favor what’s most understandable and flexible, even if it means deviating from the DNA pattern.