ben hoskings

belatedly introducing dep parameters in babushka

One weakness of babushka’s DSL has always been that deps weren’t parameterised.

It’s nice to think of a dep as something vaguely analogous to a method—with some internal structure, that side-effects usefully in some way. But method arguments, something quite standard in plain ruby (or any language really), had no analogue in the babushka DSL. Amateur hour!

tl;dr— The babushka DSL supports parameterised deps now, they’re production-ready, and old-style vars are deprecated as of yesterday.

Here is the little design story behind dep parameters, and some details on how they work.

Babushka has had shared vars since it was a prototype.

Back when I built them, I went to great effort to make vars as clever as I could. Now that I have the intervening experience, the word “clever” makes me quite nervous.

Vars are a great example of past me building big for an imagined use case. They look like this:

dep 'rack app' do
  set :vhost_type, 'unicorn'
  requires 'vhost configured'

dep 'vhost configured' do
  met? { conf_exists?(var(:vhost_type)) }
  # and so on.

Illustrated above, vars are problematic for another reason: store-and-call. When you set state and then invoke some other thing that makes use of that state (above, set :vhost_type and var(:vhost_type) respectively), that’s store-and-call.

To store in this sense is to mutate, and mutability is the root of all evil, or at least, it’s shady business. I mean you just don’t want to get involved in that kind of behaviour. Passing state around directly is much better, because:

So vars had to go, in favour of parameters of some kind. But a dep isn’t a method, so plain ruby method parameters aren’t an option.

I considered a few requirements.

Bouncing ideas back and forth with @chendo, the list of priorities emerged: the params have to feel unsurprising, while supporting lazy prompting, and some extra niceties like default values and constrained choices.

This ruled out a few possibilities, the first of which was block arguments on the dep. Had they worked, they would have looked like this:

dep 'rack app' do
  requires Dep('vhost configured').with('unicorn')

dep 'vhost configured' do |vhost_type|
  def helper
    vhost_type # argh!
  met? { conf_exists?(vhost_type) }

At first, this seemed like a great idea, but it turned out to be a flawed design.

I experimented with a couple of other designs too. Per-dep instance variables would have looked nice: a little @ badge against every var. But not so fast…

This wasn’t going well. Talking it through with @glenmaddern, we realised that the one thing the design would have to do is side-step language-level restrictions like those above. To achieve laziness, defaults, and so on, we want to run arbitrary code, and to get into that code, the argument has to be a method call.

Once we realised that, the design fell out nicely. A couple of late nights and a few test-driven classes later, and here we are.

The design uses a new notation to define dep parameters:

dep 'vhost configured', :vhost_type do
  met? { conf_exists?(vhost_type) }

Each parameter is defined on the dep as an instance method, accessible anywhere within that dep’s context. In order to supply values for the parameters, you can pass arguments along with the dep’s name, just like a method call. In order to do so I’ve monkey-patched a core class. The method is String#with:

requires 'vhost configured'.with('unicorn') # positional arguments
requires 'vhost configured'.with(vhost_type: 'unicorn') # named arguments

Referencing the parameter (i.e. calling its method) returns a Parameter object representing the value. This object is fairly transparent—you can mostly refer to it as though it were a raw value. In particular, all of these do what you’d expect:

"A fine steed" if vhost_type == 'unicorn'
"A magical #{vhost_type}"

The Parameter object is there to provide laziness. You never have to supply a parameter’s value up-front: its Parameter object will prompt for the value as required (i.e. when something like #to_s is called on it). This is nice because values that are never used won’t be asked for.

All the settings for vars are present with parameters, too, like defaults and choices. But unlike vars, which accept them as a hash of options, parameters expose them as chainable methods.

dep 'app bundled', :path, :env do
  env.ask('Which environment should be bundled?').default('production')
  # ...

But it’s more syntax!

Local parameters, passed between every dep, do seem like overhead at first. If you’ve written more than a handful of deps before, though, you’ll agree that shared vars are unmanageable over time.

In fact, parameters dotted across your deps are a good thing: think of them as explicit notation for relationships that were already there.

It’s ultimately about honesty: concision is a laudable goal, but goals need constraints. In this case, the constraint is honestly representing dependencies of state. To hide that may seem concise, but all it really does is mislead.

Find your messiest dep— chances are it’s a var’s fault.

Firstly, I apologise for that. In hindsight, vars evolved the way they did because I was too short-sighted when I was searching for a concise DSL. In my enthusiasm to make the DSL easy to dive into, I didn’t anticipate the chaos that globally accessible vars would incur. Lesson learned!

Secondly, try refactoring that dep and its friends to use dep parameters, and you’ll find that setup { } blocks and calls to #set & #define_var and such just fall away. When you can directly pass state around, you’re freed from worrying about unintended interactions between shared state that vars inevitably cause.

Feedback is always lovely, so direct your thoughts to the mailing list.

Share and enjoy!