The YADSL Rule
November 4, 2008
The Ruby testing world has a remarkable appetite for new ways to say what code ought to do. Assertions, expectations, matchers, examples, contexts, steps, shared behaviors… woulda, coulda, shoulda. A veritable cornucopia of framework!
You’ll have to forgive me. I get carried away.
There is something genuinely appealing about a well-designed domain-specific language. A test can read like a sentence. A build can look like a list of intentions. A query can describe the result we want without spelling out every step needed to get it.
But every new vocabulary sends a small bill to the person learning it. Sometimes the bill is worth paying. Sometimes we’ve bought another way to spell something we already knew how to say.
That leads me to the YADSL Rule, for Yet Another Domain Specific Language:
Don’t create a DSL if a native solution would be just as elegant.
In fact, a DSL needs to offer enough benefit to justify learning and maintaining it. A prettier first example is a promising introduction. It isn’t the whole case.
A small vocabulary can still be an extra vocabulary
Suppose we want the unpaid invoices, ordered by their due date. Ordinary Ruby already gives us a fairly direct expression:
invoices.select { |invoice| !invoice.paid? }.sort_by(&:due_on)
Now imagine a library offering this fluent alternative:
invoices.that_are(:unpaid).in_order_of(:due_on)
That second example is hypothetical, but the temptation is familiar. It reads pleasantly. It might even be a worthwhile API for a larger invoicing system. Yet for this operation, what has it bought us?
We now need to learn whether :unpaid is a recognized condition, whether
that_are filters immediately or builds a query, and how in_order_of
handles the same cases we already know how to investigate with
Enumerable.
If the answer is simply that these are new names for selection and sorting,
the gain is modest.
This isn’t an argument that domain words are bad. A well-defined unpaid
query may encapsulate an important business rule involving refunds, disputes,
and partially settled accounts. That would give the abstraction something
real to do. But if it just means !paid?, we should be honest about how much
new capability the vocabulary supplies.
The distinction matters because fluent method calls don’t automatically constitute a useful language. Sometimes they’re an ordinary API with more syllables.
A DSL that earns its keep
Consider a database query:
SELECT customer_id, SUM(total_cents) AS total_cents
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING SUM(total_cents) > 100000;
There is plenty to learn here. WHERE and HAVING do different jobs. Grouping
has rules. SQL has its share of surprises. Yet the language gives us a
coherent way to describe selection, aggregation, and filtering over stored
data, while leaving the database to choose an execution plan.
SQLite’s query-planner documentation
explains that separation between the result requested and the strategy used
to obtain it.
The benefit extends beyond the spelling of this one query. The same concepts compose into many queries, and the database can reason about the requested operation. The language is doing work.
That doesn’t make SQL a sensible replacement for every little calculation on an array. Context matters. In an application that already stores its orders in a relational database, though, the learning cost buys a substantial and reusable capability.
The same test can favor a much smaller DSL. A build description that declares dependencies can let an engine schedule work and avoid repeating completed tasks. A validation language can make constraints inspectable, rather than burying each one inside an unrelated function. The useful feature is what the system can do with the description, not how close the description gets to English.
Read the failure, not just the introduction
The showcase example for a new DSL is usually immaculate. There are two lines, they read beautifully, and everyone knows what they mean.
What happens when the expression is wrong?
Can I find the definition of the word that failed? Does an error point to my mistake, or to a generated method three layers away? Can I name an intermediate result and inspect it? What happens when I need a condition the introductory examples didn’t anticipate?
A DSL may answer those questions extremely well. A testing framework, for instance, may turn an expectation into a much clearer failure report than a bare comparison would provide. That diagnostic value belongs on the benefit side of the ledger. It is easy to overlook if we compare only character counts.
On the other hand, if using ordinary control flow requires an escape hatch, we should count the cost of learning both the language and the ways around it. An internal Ruby DSL can preserve access to Ruby’s ordinary tools, but that depends on its design; being written in Ruby doesn’t guarantee it.
Who is paying the bill?
The right audience can change the calculation. A compact notation used every day by a team may repay its learning cost quickly. An equally clever configuration language encountered twice a year may require a trip back to the manual every time.
An expert author also has an advantage the next reader doesn’t: the author already knows every word. It is easy to mistake that familiarity for simplicity. Before adding another special declaration or matcher, try explaining it to someone who knows the host language but hasn’t seen your library.
My questions for a proposed DSL are fairly practical:
- What does it express better than an ordinary function or data structure?
- What can the system inspect, check, generate, or execute because of that representation?
- Does the vocabulary compose when the problem grows beyond the first example?
- Are failures easier to understand, and unusual cases still manageable?
- Will the intended users encounter it often enough to retain what they learn?
There needn’t be a mathematical score at the end. These questions simply make it harder to confuse a new spelling with a new solution.
I write tools too, so I don’t get to stand outside this little rule and wave it at everyone else. My next beautiful interface deserves the same suspicion as yours. Possibly more: I already know how mine works.
A good DSL saves more thought than it demands. If ordinary code is already clear, let it stay ordinary. There are plenty of genuinely difficult things left to invent languages for.