If you play around with Ruby long enough you start to notice that Ruby programmers overall tend to prefer small domain specific libraries, Ruby on Rails notwithstanding. There are many good reasons for this kind of approach from a software engineering perspective but the biggest reason is that Ruby makes it extremely easy by providing the right kind of metaprogramming facilities.
When you’re writing a DSL library in Ruby you usually want to enforce certain invariants when constructing the domain model. The domain model tends to be some kind of object graph which is later going to be traversed to perform the required task. Just imagine the usual parser, interpreter pipeline except instead of having a formal grammar for describing the syntax we use Ruby and its syntax. This style of DSL is called an internal DSL. It is called “internal” because we are using the existing language facilities to give meaning to the domain constructs without stepping outside of it. There is also the external DSL approach but if the language you are working in has a flexible enough syntax then the internal approach is usually better because the language designers have already done the hard work of giving you good abstractions and error messages.
If you go for the syntax driven “external” approach then you can enforce some of the invariants with the grammar and the parts you can’t enforce with the grammar you can enforce with some kind of type checker. But if you have chosen to write an internal DSL then grammar facilities are unavailable so you have to enforce invariants through some other means. For example, if you want to express tmux layouts through a sequence of horizontal/vertical split commands then a sensible approach is to write the domain model as follows:
The above set of classes allows us to write things like this:
If you think about what each block of code does then you will notice the only example we want to be valid is “example 1”. “example 2” will complain about nil:NilClass not having a method and “example 3” is completely nonsensical because we split horizontally and then we override that decision and split vertically. So we want to give sensible error messages, or as sensible as possible at least, when “example 2” and “example 3” are executed. We can rule out both invalid examples by enforcing the right invariants with the help of Ruby’s metaprogramming facilities.
We can rule out “example 3” by not allowing split to be called more than once on a single instance and we can rule out “example 2” by being more selective with how we define accessible variables. We can do all of that as follows:
Now the above code enforces all the invariants at execution time and provides better error messages. We no longer get nil:NilClass errors because we defer defining readers for the various child nodes only when we know we have the right kinds of child nodes defined and we throw an exception if we try to split a node more than once.
This is by no means the only way to solve the problem. You could have also enforced all the various invariants by a better class hierarchy or even by keeping track of various boolean instance variables. I personally prefer using the singleton class to redefine methods instead of class hierarchies and boolean instance variables. This approach is more general and less convoluted overall because the code is not strewn with “if @some_boolean_variable” and there are no deep class hierarchies to traverse although in this case the class hierarchy would not be very deep. So the next time you are defining an internal DSL think about how the singleton class can help with enforcing invariants in your domain.