loyalty.dev

Trim your inheritance tree, one twig at a time

We hear it all the time: prefer composition over inheritance. But when dealing with a legacy code base already riddled with complex inheritance chains, where do we even start?

In this post I will outline a simple trick which can help gradually and safely migrate a class with multiple mix-ins to a composition-based design. It is far from a stop-gap solution, but it can come in handy when trying to break those chains.

A troublesome inheritance

Mix-ins are an iconic feature of Ruby, and they work amazingly well a majority of the time. They are a great abstraction to share common functionality between different classes, and as a feature, they are mostly benign. Easy to use and understand, combined with low foot-gun potential.

Sure, they sometimes get in the way of testability, especially when it comest to unit testing, since they are hard to test in isolation, and we often end up tempted to stub methods on our test subjects. But these problems can generally be worked around, and can reasonably be considered a fair trade-off for the practicality that using mix-ins offers.

The real problem with mix-ins isn't really with mix-ins themselves. It starts when you have more than a handful of them mixed into the same class. Suddenly tracing the execution path of our code becomes a chore. Where, exactly, is this method defined? We start spending more and more time bouncing around files, constructing call stacks in our mind to get a grasp on what's going on.

Mix-ins work almost identically to inheritance. (There are still some differences, like mix-ins not affecting the superclass reference.) Whenever we include a module in our class, we're adding one more level to the class' lineage.

class UseCase
  include SharedBusinessLogic
end

UseCase.ancestors
#=> [UseCase, SharedBusinessLogic, ...]

(We can even prepend modules to our class, which let's us do some really wild things.)

Ruby's method lookup traverses up this ancestor chain in search for the first instance of the method being called. As is usually the case, Ruby affords us a great deal of power with just a single method call, as this allows us to call any instance methods defined in the mixed in Bar module as if they were instance methods defined on the Foo class.

Specifically, this means we can call those methods without an explicit receiver, which is how we start losing track of where the methods are defined. Mix-ins can call methods in other mix-ins the same way, which is another (even deeper) trap to look out for.

Yes, you can use a fancy editor to find these definitions more easily, but jumping around files tends to trash our working memory (I sometimes wince when I see a call to super), and occasionally the same method is defined in multiple mix-ins, and we need to figure out which one is being called.

The source of this titular problem is inheritance, and since mix-ins are more or less the same thing as inheritance, they are prone to the same problems. Their ease of use tends to lead to a gradual build-up. A slow, calcifying death. A death by a thousand cuts. Things deteriorate slowly, then suddenly.

Composition is the answer that is often touted as the solution to inheritance trees that have outgrown their creators. Separate your business objects into collaborative units, test those units in isolation, then arrange (and re-arrange) them in ways that solve any use case.

One small step towards composition

There are multiple ways we can exploit composition to better factor our applications. Which one is right for you and your project is impossible to tell without knowing the exact details of the code that you are working on, and might vary on a case-by-case basis in the same application.

That's why this post is focused around where to start, but says little about where you'll end up. Whether you want to use direct coupling, some rich library for dependency injection, or roll your own, constructor based implementation is up to you.

What we do know is that, no matter the method, many implicit method calls will ultimately end up with explicit receivers (or being explicitly delegated to one.) What if there was a way to introduce these explicit receivers without immediately replacing our mix-ins?

That could aid the refactoring by turning one big, risky step into several smaller, safer ones. It could also alleviate some of the pain of having to scurry around our code base just to locate our method definitions in the interim. Finally it could help us reveal any hidden dependencies between different mix-ins.

Well, we can very easily come up with the most trivial solution to this. Imagine the following (heavily simplified) checkout class, which receives some shared payment-, order tracking-, and support ticket functionality from some of its mix-ins:

class Checkout
  include CustomerSupport
  include OrderTracking
  include PaymentSteps

  def call
    order   = create_order(line_items)
    payment = pay(payment_details)

    if payment.success?
      order.ship
    else
      order.cancel

      create_support_ticket(order_details)
    end
  end
end

Since we already know that these methods are being dispatched to the instance of Checkout, we can alias the instance itself to effectively introduce explicit receivers for our soon-to-be dependencies using a simple one-liner, and immediately put that to use in our code:

class Checkout
  include CustomerSupport
  include OrderTracking
  include PaymentSteps

  def call
    order   = order_tracking.create_order(line_items)
    payment = payment_steps.pay(payment_details)

    if payment.success?
      order.ship
    else
      order.cancel

      customer_support.create_support_ticket(order_details)
    end
  end

  alias_method :customer_support, :itself
  alias_method :order_tracking,   :itself
  alias_method :payment_steps,    :itself
end

Note that this is still just inheritance masquerading as composition, and that this is an intermediate state of our code. How long it will be around depends on the size and complexity of the code you're working with and how much time you have for refactoring, but the ultimate goal should be to eventually move to actual composition.

Our tests should still pass at this point. Now we can go and implement the objects which will replace the mix-ins. From there we can remove each of the mix-ins and have the new methods return the relevant dependencies, using the implementation of our choice, and our tests should again pass.

For the purpose of illustration, using simple, constructor-based dependency injection (again, this is only one of several available options), we could end up with something like:

class Checkout
  def initialize(order_tracker:, payment_processor:, customer_support:)
    @order_tracker     = order_tracker
    @payment_processor = payment_processor
    @customer_support  = customer_support
  end

  def call
    order   = @order_tracker.create_order(line_items)
    payment = @payment_processor.pay(payment_details)

    if payment.success?
      order.ship
    else
      order.cancel

      @customer_support.create_support_ticket(order_details)
    end
  end
end

In this simplified example, the intermediate step might seem redundant, and it probably is. The value of this approach is in giving you more granular steps to work with, in order to gradually and safely refactor a larger code base.

Don't cut the branch you're sitting on

This is the obligatory disclaimer. This is a very specific solution that fits a very specific situation. Don't use the above because I told you. Don't use it because it's a neat trick. Use it because you were missing a piece, and this seems like it just might fit. Or stash it away somewhere in a drawer in the back of your mind until you need it. When you do, it will come to you.

Also, this is far from a perfect solution. We could, for instance, call customer_support.pay, and it would still work, because all we are doing is delegating to the instance itself. There is no connection between an alias and any particular mix-in.

You could build a better version of this, but you should be careful not to invest too much in something that is meant to be an interim solution in the first place. Especially as this class of errors should be picked up by our tests once we replace the mix-in.

You might also ask if the code above is a good candidate for this change, and that is a great question to ask. Prefer composition over inheritance is different from always use composition, never inheritance, and the fictional example above is purely for illustrating the technique.

Ultimately we must resign to the fact that programming is not painting by numbers, and that no matter how many sayings and acronyms we can recite we will, however unfortunate, still need to rely on our own professional judgement. This is just another tool to put (probably somewhere towards the bottom) in our toolbox, and if we never need to use it, all is still well.