Opinionated Modular Code

Mar 28, 2010

When you start writing modular code, and using techniques like dependency injection, you end up with a lot of pieces, but not necessarily an obvious whole. Working with java libraries can be an exercise in instantiating huge towers of dependencies to finally get to the object you actually need.

  val socket    = new TSocket(host, port)
  val protocol  = new TBinaryProtocol(socket)
  val client    = new TCassandra.Client(protocol)
  val cassandra = new Cassandra(keyspace, client)

My scala wrapper around cassandra's thrift bindings depends on an instance of TCassandra.Client, which depends on an instance of TProtocol (of which TBinaryProtocol is a subclass), which depends on an instance of TTransport (TSocket is subclass).

Thrift is necessarily modular. The user of a thrift service might wish to use a different TProtocol implementation or a non-blocking socket (TNonblockingSocket). Still, though, 4 lines of setup code just to get a simple cassandra client is cumbersome.

Contrasted with tightly coupled code, this appears to be a major drawback of composability. With coupled code, if you need an object, you just instantiate it and it creates — or refers directly to — all of the collaborators it needs. It's easy to imagine a tightly coupled version of my wrapper that instantiates hard dependencies.

class Cassandra(host: String, port: String) {
  val socket   = new TSocket(host, port)
  val protocol = new TBinaryProtocol(socket)
  val client   = new TCassandra.Client(protocol)

In a relatively high percentage of use cases, this set of defaults is perfectly acceptable. This kind of opinionated code is really easy to get started with, but can become problematic later on when its user decides she really does need that non-blocking socket. So, what to do?

It turns out it's possible to have it both ways. This is going to sound so simple that it's silly… but just create a second constructor that invokes the first one.

class Cassandra(client: TCassandra.Client) {
  def this(host: String, port: Int) = this(
    new TCassandra.Client(
      new TBinaryProtocol(
        new TSocket(host, port)

This lowers the bar considerably to getting started with my Cassandra client. If somebody just wants to give it a try or whip up a quick and dirty program, they can do it easily — likely without even reading any documentation. But as the user's needs become more complex, I haven't shut them out of customizing the client to their heart's content.

Of course, as your object models become increasingly complex, there will be some additional effort required to maintain these auxiliary constructors. And arguably, you will create some degree of coupling between the classes. But it's mostly harmless. Provided it's possible to supply alternate dependencies, you have accomplished modularity. We're just adding sane defaults.

In ruby, I use default arguments to accomplish the same goal.

def initialize(klass, storage_factory = StorageFactory.new, table_creator=TableCreator.new)
  @klass           = klass
  @storage_factory = storage_factory
  @table_creator   = table_creator

This is an example from friendly's code base. The StorageProxy needs a StorageFactory and a TableCreator, so I create them if they aren't supplied.

This is all possible because the StorageFactory and TableCreator's default dependencies are set in exactly the same way. I think the only place I ever supply alternates is when I inject test doubles in StorageProxy's specs.

This makes for an object model that is really easy to work with, yet still highly modular. It's quick to get started with, but doesn't get in your way when you need to reach in and change some shit around. Making your classes both modular and convenient gets you the best of both worlds.