Building an Intuitive DSL in Java
A DSL is more than chaining a few methods together. A great DSL should be human readable, which means considering word order and consistency.
Join the DZone community and get the full member experience.
Join For FreeWhen I started RuleBook, I used just a simple method chaining Domain Specific Language (DSL) that also leveraged Java 8's new Lambda functionality. However, as my desire to enhance the [internal] DSL and create a better experience for the language grew, I found just having a couple of objects that chained their own methods together wasn't enough.
The Order of Words Matters
If the goal of a DSL is to create a human readable English-like language, then even an internal Java DSL that builds a Domain Specific Language out of a General Purpose (GP) language must strive to follow some rules of continuity. Take, for example, the Given/When/Then (GWT) language format associated with Business Driven Development. If Given <Some State> When <Some Condition> Then <Some Action> makes sense. But Then <Some Action> Given <Some State> When <Some Condition> doesn't make as much sense, especially if both statements are equivalent according to a DSL.
Now, let's throw a few more monkey wrenches into the system. It's pretty common for multiple 'then' statements to be allowed in GWT languages. Also, let's assume that there is an optional 'using' keyword that can be used to constrain the state available to a subsequent 'then' statement (like in RuleBook). Now, even if the language doesn't apply ordering constraints, it becomes pretty clear that the order of the keywords used matters. And since order matters for the language to properly translate into the desired functionality, the language should apply the necessary constraints on that order to guard against unwanted behavior.
Enforcing Order
Let's take a simple GWT format DSL interface that uses method chaining via a builder.
public interface GwtBuilder {
GwtBuilder given(Data data);
GwtBuilder when(Condition cond);
GwtBuilder using(DataFilter filter);
GwtBuilder then(Action action);
MyObject build();
}
Using the above interface, both of the following code snippets are valid.
GwtBuilder builder = new GwtBuilderImpl();
MyObject obj = builder
.then(action1)
.given(data)
.then(action2)
.using(dataFilter)
.build();
GwtBuilder builder = new GwtBuilderImpl();
MyObject obj = builder
.given(data)
.when(condition)
.using(dataFilter)
.then(action)
.build();
However, the first code snippet doesn't make a whole lot of sense. For example, if using() filters data for a then() action, which action is it applied to? Then there's how it reads. "Then some action happens, given some state, then some other action happens using some subset of the state" doesn't exactly have a logical linguistic flow. It would make more sense to have the language enforce some rules like the following:
- Only a 'given', 'when', 'using' or 'then' method can be the first method.
- Only a 'given', 'when', 'using' or 'then' method can follow a 'given' method.
- Only a 'then' or 'using' method can follow a 'when' method.
- Only a 'using' or 'then' method can follow a 'using' method.
- Only a 'using', 'then' or 'build' method can follow a 'then' method.
Using these rules, the first builder's sequence of method calls becomes invalid. However, this can't be done with a single builder. For each unique rule, a unique builder is required. Therfore, enforcing order using method chaining is done by chaining builders.
public interface GwtBuilder {
GwtBuilder given(Data data);
GwtWhenUsingBuilder when(Condition cond);
GwtWhenUsingBuilder using(DataFilter filter);
GwtThenBuilder then(Action action);
}
Taking the example a step further, the GwtThenBuilder interface might look like the following:
public interface GwtThenBuilder {
GwtThenBuilder then(Action action);
GwtWhenUsingBuilder using(DataFilter filter);
MyObject build();
}
Consistency Matters Too
What makes something intuitive? Is it a result of natural instinct or conditioning? Let's take driving a car for example. Most of us reading this probably know how to drive a car. But certainly none of us were born knowing how to drive a car. Yet right now, if someone handed us some car keys, we would likely know how to use those keys to drive a car. And that's true for just about any car. It's intuitive because it's known. And similarly, if you know how to drive one car, you know how to drive almost every other car. I imagine if there was a car where the normal rules of how to drive a car didn't apply then not many people would drive it. That's because people gravitate to what's known. And what's known or what can easily be known through conditioning is what we refer to as intutive.
Like driving a car, or the old adage about riding a bike, languages that have a familiar and consistent set of rules can be said to be intuitive. And intutive languages are easier to learn and adopt than unintuitive languages. Domain Specific Languages are no different. So, if you want to create one that is 'intuitive' and easy adopt, keep the options limited, keep its use familiar and keep its ruleset concise.
Using the Tools in the Java DSL Toolbox
There are a few approaches to constructing an internal DSL out of the Java language. The common approaches are:
Method Chaining
GwtBuilder builder = new GwtBuilderImpl();
MyObject obj = builder
.given(data)
.when(condition)
.using(dataFilter)
.then(action)
.build();
Method Sequencing
MyObject obj = new MyObject();
GwtDsl gwtDsl = new GwtDslImpl(obj);
gwtDsl.given(data);
gwtDsl.when(condition);
gwtDsl.using(dataFilter);
gwtDsl.then(action);
Nested Method Calls
GwtDsl gwtDsl = new GwtDsl();
MyObject obj = gwtDsl.createMyObject(given(data, when(condition, then(action))));
Lambda Expressions
GwtDsl gwtDsl = new GwtDsl();
MyObject obj = GwtDsl(data -> someAction.perform(data));
//the above line could be a method reference; lambda syntax was used for illustration
When constructing a single statement, method chaining may seem like a great choice for everything. But what happens if you want to combine statements. If you use method chaining for constructing individual statements and then also use it for combining statements then the demaracation between statements can be confusing. Take the following example.
builder.add().given(data1).when(cond1).then(action1).add().given(data2).when(cond2).then(action2);
Here, using one approach to define a statement and another to create a strict separation between statements would make the language easier to read. But this is also where consistency comes into play. You wouldn't want method chaining to be used for creating statements, while method calls are used for separating statements sometimes and have method sequencing used for separating statements other times. Such inconsistencies can make any DSL difficult to learn and use and they are easy traps to fall into.
Summary
Building an intuitive [internal] Domain Specific Language (DSL) takes more than just following commonly accepted development practices, like abstracting the expression language from the API. It requires a thoughtful understanding of how the language should be used and how it should not be used. Attention to things like the order of words and consistency within the language can go a long way towards making a language both usable and easy to understand.
Opinions expressed by DZone contributors are their own.
Comments