DSL – domain specific language. DSLs are often touted as the silver bullet to let “users write their own business rules”. They’re even more often used when they shouldn’t be, and just end up complicating things. But they are fun. And used correctly, they can be a really powerful, highly expressive tool.
A well-known example, and one of my personal favorites, is of course the ScalaTest spec/assertion dsl. It lets you write highly expressive assertions like:
myBooleanResult should be (true)
And, when you see that for the first time, you might ask
- How the heck does that work?
- Isn’t that just
myBoolean == true
?
To which I’d answer:
- Scala supports infix notation
- Not even close.
Packing a Punch
There’s a multitude of reasons why should be
is far superior to ==
. Right now I’ll hit the highlights and get on to teaching you to write you own, similar, dsl.
The combination of should be
creates a layering of classes that allow us to compare myBooleanResult
and true
, but get natural language success and error messages, i.e.:
SUCCESS: myBooleanResult was true
FAILURE: myBooleanResult should have been true
instead of:
SUCCESS: myBooleanResult == true
FAILURE: myBooleanResult != true
This is just one (trivial) example of why ScalaTest is so great, but it should give you an idea of how dsl’s look and feel in Scala.
What’s In a DSL
Let’s start from the abstract. If we want to write code that looks like natural sentences, let’s start by examining natural sentence structure.
Juliet visits Grandma.
A mundane enough sentence, wouldn’t you say? Let’s break it down.
We have:
- “Juliet” – our subject
- “visits” – our verb
- “Grandma” – our direct object
So that means we’d need an API that cpatures those concepts. In regular old code it might look like:
class Subject () { def visits(obj: DirectObject): Unit = ??? }
class Visitable extends DirectObject
val Juliet = new Subject
val Grandma = new Visitable
Juliet.visits(Grandma)
Which is just fine but there’s just something special about writing:
Juliet visits Grandma
in our source code.
A generalized/stubbed API might look like this:
package dsl
object Abstract {
class SubjectWord {
def verb(obj: DirectObject): Any = ???
}
class DirectObject
val mySubject = new SubjectWord()
val myObject = new DirectObject()
mySubject verb myObject
}
Serving It Up
Let’s try creating a dsl to do something (semi-) useful!
We’ll write an API that lets us simulate a catering business or restaurant.
It will have:
- Guests (people) organized into parties
- Guest’s food preferences
- Servers who bring food to parties of guests
Servers will serve food on a First-Come-First-Serve basis to anyone with preference for that food (I didn’t say it was a competent catering business!)
Let’s start with defining our “service” interface, i.e. the business logic… aptly named Server
in our domain!
This server will:
- be assigned to a party (group of people)
- serve food to the party as it becomes ready, based on the rules we described above.
We’ll take a stab at writing our Server
with our ideal dsl, then we’ll go about implementing it!
class Server(party: Seq[Person]) {
def serves(food: Food): Unit =
party.collectFirst {
case person if person hasPreferenceFor food => person
} match {
case Some(person) => person gets food
}
}
We see:
- We’ll need a way to check someone’s preference (
hasPreferenceFor
) - We’ll need a way for a person to receive food (
gets
)
Let’s start on our person implementation:
class Person() {
def hasPreferenceFor(food: Food): Boolean = ???
def gets(food: Food): Unit = ()
}
Simple enough? Just two 1-Arity methods that we can call with infix notation.
But how do we implement hasPreferenceFor
? With more dsl of course!
class Person() {
private var preference: Option[Food] = None
def prefers(food: Food) =
preference = Some(food)
def hasPreferenceFor(food: Food): Boolean =
preference.contains(food)
...
}
We add some state (preference
) to our Person, which starts out as None
(i.e. prefers no food). We have a prefers
method that will set the Person’s preference (in Java, it might be called setPreference
). And hasPreferenceFor
simply checks equality of the incoming food against the Person’s preference, and when preference == None
they will not prefer any incoming food.
What do our food(s) look like? They can be as simple or complex as your implementation needs them to be. For this tutorial, we’ll just use a couple of case objects
:
trait Food
case object Pizza extends Food
case object Salad extends Food
And that’s it! Now we can use our dsl!
val John = new Person()
val Sarah = new Person()
val johnAndSarah = Seq(John, Sarah)
val Martin = new Server(johnAndSarah)
John prefers Salad
Sarah prefers Pizza
Martin serves Pizza // Sara gets Pizza!
Martin serves Salad // John gets Salad!
John prefers None
Martin serves Salad // No one gets Salad!
No .
or ()
to clutter our code! Now you’re ready to write readable, natural code.
Exercises
Some exercises that are left to the reader:
- How to add indirect objects?
- In a group of many people, servers would be serving the same person over and over again! How can you improve the
Server
so that once a person has received food, the people further down the line get served once that food comes around again? - Furthermore from (2) – can you make it so people eventually finish eating their food? And after they finish, they can receive a second helping of their food-of-preference.
- Once (3) is done, the Server will definitely want to keep track of who had what, and in what quantities! Can you itemize a bill?