A simple pattern for cleaning up your Ruby DSL

I’ve recently been working on a project that involved building a Ruby DSL. As an experiment, I decided to use only Cucumber to describe the behaviour of the code and leave the unit tests until later. I quite enjoyed this way of working at first, as it forced me to maintain focus on the end user experience and worry less about describing the stuff underneath.

A negative side effect of this approach was that without unit tests keeping me in check, things got a little messy. In lieu of a separate class responsible for DSL behaviour, DSL methods ended up sitting alongside non-DSL methods without much indication of their intended use to users or future maintainers. Worse, sometimes the DSL methods would be the only entry point to a particular feature.

The following code demonstrates the problem on a small scale. I’ll leave it up to you to imagine the same situation with four or five times the number of methods.

The main class reponsible for the DSL would look something like this:

class Config
  attr_reader :file_paths

  def initialize(&block)
    @file_paths = []
    self.instance_eval(&block) if block_given?
  end

  # DSL method
  def files(*paths)
    @file_paths += paths.map { |p| File.expand_path(p) }
  end

  # non-DSL method
  def save!
    @file_paths.each do |path|
      File.new(path, 'w')
    end
  end
end

Intended use follows the builder pattern, with some of that instance_eval magic that you either love or hate:

Config.new do
  files 'foo.bar', 'baz/qux.quux'
end

Fine. But we have a problem when a user would rather not use the code in this manner. Ideally, a block-based DSL like this would be an optional nicety and not the sole way of getting things done.

This problem becomes even more obvious when writing unit tests — we are users of our own code, after all — and it’s necessary to keep on initializing new objects in various states of configuration by using the DSL block. Not to mention that use of mock objects is made practically impossible in this case thanks to the altered scope from instance_eval.

To fix this, I settled on the following solution.

require 'delegate'

class Config
  def self.build(&block)
    config = new
    delegator = ConfigDelegator.new(config)
    delegator.instance_eval(&block)
    config
  end

  attr_reader :file_paths

  def initialize
    @file_paths = []
  end

  def add_paths(*paths)
    @file_paths += paths.map { |p| File.expand_path(p) }
  end

  def save!
    @file_paths.each do |path|
      File.new(path, 'w')
    end
  end

  class ConfigDelegator < SimpleDelegator
    def files(*paths)
      add_paths(*paths)
    end
  end
end
Config.build do
  files 'foo.bar', 'baz/qux.quux'
end

I’m sure the idea is nothing new, but it has a number of benefits.

By using a delegator object we can isolate the DSL methods, making them only available inside the build block. The footprint of the DSL is now clearer, since DSL methods are defined on the delegator class.

Ruby DSL behavior frequently deviates considerably from the concept of simply constructing objects and passing messages between them. In the interest of providing a simple user experience, we are tempted to write complex behaviour into a DSL method. Separating our concerns presents the opportunity to keep a watchful eye on this complexity as it inevitably grows.

Cleaning up like this also allows for better unit testing. In the example above, the action of adding paths can be isolated without having to touch the DSL at all. What’s more, the user can ignore the DSL completely if they wish.

config = Config.new
config.add_paths('foo.bar', 'baz/qux.quux')
config.save!

A combination of unit tests for the non-DSL methods and acceptance tests for the DSL methods seems like a good fit to me.

It turns out this behavior is easy to extract into a module for reuse too.

require 'delegate'

module DSL
  def build(*args, &block)
    base = self.new(*args)
    delegator_klass = self.const_get('DSLDelegator')
    delegator = delegator_klass.new(base)
    delegator.instance_eval(&block)
    base
  end

  def dsl(&block)
    delegator_klass = Class.new(SimpleDelegator, &block)
    self.const_set('DSLDelegator', delegator_klass)
  end
end
class Config
  extend DSL
  # ...
  dsl do
    # DSL methods defined here
  end
end

The delegator class is created when the dsl class method is used. The class is assigned to the constant DSLDelegator and namespaced under the extending class.

My favourite thing about this approach is that it brings added clarity of thought. Simply having a clear place for the DSL methods to live and having them on hand in the same file as the class to which they relate has made iterative development on the project a little quicker and lot more enjoyable.