Inject Roles, Not Objects


This post focusses on a specific aspect of Dependency Injection as applied to Ruby, namely: how should we think about the things that we’re injecting?

Originally posted on 2019-02-04 on the Varvet blog

Consider the following Ruby code:

# app/services/widget_assembler.rb

class WidgetAssembler
  def assemble(options)
    gadget = GadgetAssembler.new.assemble(options)
    Widget.new(gadget)
  end
end

It’s an over-simplified example, but contains enough to discuss some ideas about coupling and flexibility.

Coupling

The amount of information that an object knows about the other objects it interacts with (its ‘collaborators’) is a form of of Coupling, and coupling is something we want to minimise, since it makes our code harder to change, and harder to test.

Let’s list out the information that our WidgetAssembler knows about this gadget_assembler collaborator object:

  1. that we’re using a specific object called GadgetAssembler
  2. it is a regular Class (rather than a module or singleton), since it accepts a .new message
  3. instances can be built without passing any arguments
  4. instances accept an assemble method
  5. the assemble method takes a single argument

Dependency Injection

A few years ago I was working on a team where all of us had only just started to discover Object Oriented design and were trying to apply it to our Rails project. In particular we were using Dependency Injection all over the place and I started seeing code like this:

# app/services/widget_assembler.rb

class WidgetAssembler
  def initialize(gadget_assembler = GadgetAssembler)
    @gadget_assembler = gadget_assembler
  end

  def assemble(options)
    gadget = @gadget_assembler.new.assemble(options)
    Widget.new(gadget)
  end
end

This is a small improvement: we can cross off point 1, since we’ve decoupled this code from knowing the name of the collaborator by injecting the class object.

Note: we’re still passing the class object as a default value in the initializer, but this is just for convenience and it shouldn’t be considered part of the signature of this object. More on this at the end of the post.

However WidgetAssembler still knows quite a bit about its collaborator, and there’s still a problem regarding testing. Our test setup might look something like this (using RSpec):

# spec/services/widget_assembler_spec.rb

gadget = instance_double('Gadget')
gadget_assembler = instance_double('GadgetAssembler', assemble: gadget)
gadget_assembler_klass = class_double('GadgetAssembler', new: gadget_assembler)
widget_assembler = WidgetAssembler.new(gadget_assembler_klass)

We need to create three doubles here, and we need to stub the new method, which feels awkward: the method we actually define is called initialize, so it’s not obvious that we entirely own the method that we’re stubbing.

In this implementation we can also see that WidgetAssembler has a potentially large surface area of methods that it might expect to call: not only the methods of the object returned by calling .new, but the class methods of the gadget_assembler object itself.

We can do better

What happens if we inject an already-built instance of GadgetAssembler instead?

# app/services/widget_assembler.rb

class WidgetAssembler
  def initialize(gadget_assembler = GadgetAssembler.new)
    @gadget_assembler = gadget_assembler
  end

  def assemble(options)
    gadget = @gadget_assembler.assemble(options)
    Widget.new(gadget)
  end
end

We’re now left only needing to be concerned with two items of information about our collaborator:

  • the name of the method we can call on it
  • the arguments to that method

Our test setup also becomes simpler, which is usually a sign that we’re moving in the right direction:

# spec/services/widget_assembler_spec.rb

gadget = instance_double('Gadget')
gadget_assembler = instance_double('GadgetAssembler', assemble: gadget)
widget_assembler = WidgetAssembler.new(gadget_assembler)

Injecting roles

Another way to think about this iteration of the code is to consider what we could pass in as the collaborator:

  • An instance with assemble as an instance method
  • A class with assemble as a class method
  • A module with assemble as a module method

Our WidgetAssembler really doesn’t care what kind of object the collaborator is: as long as it plays the role of a gadget_assembler, everything works and no changes are required to the application code.

This is an important point which I feel is often overlooked: when we start caring about or relying on a particular type of object being injected, we have in a way reintroduced some of the coupling which our dependency injection removed.

This is perhaps in part a side effect of two aspects of working with Ruby which make it easy to fall into thinking of dependency injection in terms of injecting specific objects rather than more abstract roles:

  • Firstly, in Ruby we have this nice convenience of being able to initialize an object and pass it as a default value in our initializer. That means we’re writing a specific class name in our application code, even though we could inject any object which fulfils the same role.
  • Secondly, in RSpec, in order to make sure that we’re not writing ‘imaginary tests’ where our test doubles don’t actually behave like the real objects that they’re standing in for, we use verifying doubles, where we again write the name of a specific class, even though the intent of our double is to model a specific behaviour rather than a specific object.

In conclusion…

An often-quoted directive of testing is to “Mock roles, not types”, or “Mock roles, not objects”. I think it’s also true that we can think more clearly about dependency injection if we remember to “inject roles, not objects”.

Footnote: on Demeter

When I started writing this post I thought it was going to focus on how @gadget_assembler.new.assemble(options) was a violation of the Law Of Demeter, but oddly it seems not to be, despite the fact that there are “two dots”. The Law Of Demeter says that it’s fine for a method to call other methods on objects which are created inside the method itself.

Perhaps the reason is that in Ruby, classes themselves are objects, and creating an instance is achieved by calling a method on that object, which is not the case in many languages. I wasn’t able to get a clear understanding of how these things fit together, but if you have any insights, please let me know in the comments.