building dsls with scala
TRANSCRIPT
When you order coffee, what do you ask for?
Once a language’s corner stones are established, its common vocabulary can be expanded.
Introduction to DSLs
01
A domain-specific language (DSL) is a computer language specialized to a particular application domain.
“ “
DSLs are made to fit their purpose only.
How is DSL different from any other programming language?
●It is targeted at a specific problem domain●It offers a higher level of abstraction to the
user
Coffee DSL
SQL
CSS
Simple mapping is the challenge…
Problem Domain
Solution Implementation
Essential Complexity
Accidental Complexity
● Sending a rocket to space
● Building a search engine
● Supporting high load of
traffic
● Writing unclear code
● Over engineering
● Using the wrong tool for the
job
DSLs reduces the accidental complexity
DSLs reduces the accidental complexity
Why Scala ?
02
Q&AFlexible syntax, fit for abstraction
"123".contains("2") <==> "123"contains("2")
val a = 2 <==> val a = 2;
"123".contains("2") <==> "123"contains2""
val name = "scala" <==> val name: String = "scala"
Optional dots in method invocation
Semicolon inference
Optional parentheses
Type inference
Q&AWe can easily pass complex logic using simple expressions
Concise lambda syntax
numbers map { x => x+1}numbers sortWith { (x,y) => x>y }numbers map {_+1} sortWith { _>_ }
Q&ACase classes - perfect for abstraction design
case class Name ( val first: String = "hello", val last: String =" world ")
val myName = Name(first = "James", last =" Bond")
Scala Implicits ●provide an implicit conversion from type to type
●statically typed
●allow us to create Lexically scoped open classes
Scala Implicits params example
object Main extends App { def sayHello(name: String)(implicit greeting: String) { println(s"$greeting, $name") } implicit val greeting = "Hi" sayHello("George")}
Scala Implicits conversiondef intOnly(int: Int) = { . . .}
intOnly(1.1)
“Type mismatch, expected Int actual Double”
Scala Implicits conversion
def intOnly(int: Int) = { . . .}
intOnly(1.1)
implicit def double2int(x: Double): Int = x.toInt
Scala open classes
Map(1 -> "one", 2 -> "two", 3 -> "three")
implicit final class ArrowAssoc[A](self : A) { @scala.inline def ->[B](y : B) : scala.Tuple2[A, B] = { /* compiled code */ } def →[B](y : B) : scala.Tuple2[A, B] = { /* compiled code */ }}
Lets build a DSL !
03
We are building a geo-based tax rule matching system for shopping carts
15%
12%12%
25%
In English, please● “for Canada the tax is 15% of cart total"● “for UK the tax is 12% of cart total, when calculating the tax ignore the shipping cost” ● “for USA the tax is 7% of cart total, when calculating the tax ignore the shipping cost and the
discount” ● “for Finland the tax is 5% of cart total, when calculating the tax ignore discount if shipping
costs larger than 4€"● “for Israel the tax is 25% of cart total, add a special custom tourist tax”
We will want to translate this to a list of requirements
● The tax for a shopping cart is calculated by the shipping
country
● Tax is different for each country
● Tax calculation is depended on other factor like shipping
and discount
System requirements
● Clear communication between project teams
● Tests speak the same language and can be reviewed
by non technical personal
Benefits of a common vocabulary
Break it down ● “for Canada the tax is 15% of cart total ”
● “for UK the tax is 12% of cart total, when calculating the tax ignore the shipping cost”
● “for USA the tax is 7% of cart total , when calculating the tax ignore the shipping cost
and the discount ”
● “for Finland the tax is 5% of cart total , when calculating the tax ignore discount if
shipping costs larger than 4 €”
● “for Israel the tax is 25% of cart total, add a special custom tourist tax”
●Tax is calculated on a cart
●Cart is shipped to a specific country
●Cart might or might not have a discount ….
Understanding the relationship
Create domain abstractions
object Cart { val shipping = (c: Cart) => c.shipping val discount = (c: Cart) => c.discount}
case class Cart( total: BigDecimal, shipping: BigDecimal, discount: BigDecimal, country: Country)
Create domain abstractionssealed trait Countryobject Countries { case object Israel extends Country case object USA extends Country case object FINLAND extends Country case object UK extends Country Case object CANADA extends Country} type CartPredicate = Cart => Boolean type TaxCalculator = Cart => BigDecimal type CountryToTaxCalculation = (Country, TaxCalculator)
“for Canada the tax is 15%”def For(c: Country): CountryContainer = { CountryContainer(c)}
case class CountryContainer(country: Country) { def take(tax: BigDecimal): CountryToTaxCalculation = { (country, (cart: Cart) => cart.total * tax) }}
“for Canada the tax is 15%”
For(CANADA).take(0.15)
For(CANADA) take 0.15
whats going on under the hood ?For(Canada) take 0.15
CountryContainer
(returns) (invokes)(returns)
CountryToTaxCalculation
“for UK the tax is 12%, ignore the shipping costs”
Already supported
No!
Can we extend our solution to support this part?
Next up
Add an intermediate step when needed
case class CountryContainer(country: Country) { def take(tax: BigDecimal): CountryToTaxCalculation = { (country, (cart: Cart) => cart.total * tax) }}
old
case class CountryContainer(country: Country) { def take(tax: BigDecimal): TaxAndCountry =
TaxAndCountryContainer(country, tax)}
new
Extend the vocabulary when needed
case class TaxAndCountryContainer(country: Country, tax: BigDecimal) {
}
def ignore(cartField: CartField): CountryToTaxCalculation = (country, (c: Cart) => (c.total - cartField(c)) * tax)
For(UK).take(0.12).ignore(shipping)
For(UK) take 0.12 ignore shipping
“for UK the tax is 12%, ignore the shipping costs”
What's going on under the hood ?For(UK) take 0.12 ignore shipping
CountryContainer
(returns) (invokes)(returns)
TaxAndCountry Container
(invokes)(returns)
CountryToTaxCalculation
will “for Canada the tax is 15%” still work ??
For(CANADA) take 0.17
CountryContainer
(invokes)(returns)(returns)
TaxAndCountryContainer!= CountryToTaxCalculation
Fix when needed
implicit def taxAndCountryToTaxCalculation (taxAndCountry: TaxAndCountry): CountryToTaxCalculation = { (taxAndCountry.country, { (cart: Cart) => (cart.total * taxAndCountry.tax) })}
What's going on under the hood ?For(CANADA) take 0.17
CountryContainer
(invokes)(returns)(returns)
TaxAndCountry
(implicit conversion)
CountryToTaxCalculation
Workflow Break the English rules down
Understand the relationships
Create domain abstractions
Implement logic structures
When needed:• Add an intermediate step• Extend the vocabulary• Fix previous abstractions
Can we improve the ignore function to take multiple params?
“for USA the tax is 12%, when calculating the tax ignore the shipping cost and the discount ”
Next up
TaxAndCountry
def ignore(cartField: CartField): CountryToTaxCalculation = (country, (c: Cart) => (c.total - cartField(c)) * tax)
old
def ignore(cartFields: CartField*): CountryToTaxCalculation = (country, (c: Cart) =>
(c.total - cartFields.foldLeft(BigDecimal(0))((a, f) => a + f(c))) * tax)
new
“for USA the tax is 12%, when calculating the tax ignore the shipping cost and the discount ”
For(USA).take(0.12).ignore(discount, shipping)We can do better...
taking it to the next level object Cart { val shipping = (c: Cart) => c.shipping val discount = (c: Cart) => c.discount}
object Cart { val shipping = new CartCombinator((c: Cart) => c.shipping) val discount = new CartCombinator((c: Cart) => c.discount)}
CartCombinator
case class CartCombinator(val cartField: CartField) {
def and (comb: CartCombinator): CartCombinator = new CartCombinator((c: Cart) => comb.cartField(c) + cartField(c))
}
TaxAndCountry – with combinatorscase class TaxAndCountry(country: Country, tax: BigDecimal) {
def ignore(combs: CartCombinator*): CountryToTaxCalculation = {
def calculateTax(cart: Cart): BigDecimal = (cart.total - combineAllRules(cart)) * tax
def combineAllRules(cart: Cart): BigDecimal = combs.foldLeft(BigDecimal(0))((a, b) => a + b.cartField(cart)) (country, calculateTax)
}
“for USA the tax is 7%, when calculating the tax ignore the shipping cost and the discount ”
For(USA).take(0.07).ignore(discount and shipping)For(USA) take 0.07 ignore discount and shipping
Let’s add the & operator
class CartCombinator(val cartField: CartField) {
def and (comb: CartCombinator): CartCombinator = new CartCombinator((c: Cart) => comb.cartField(c) + cartField(c))
def & (occ: CartCombinator): CartCombinator = and(occ)
}
“for USA the tax is 7%, when calculating the tax ignore the shipping cost and the discount ”
For(USA).take(0.07).ignore(discount and shipping)For(USA) take 0.07 ignore discount & shipping
operator precedence by order(all letters)|^&< >= !:+ -* / %(all other special characters)
What's going on under the hood ?For(USA) take 0.07 ignore discount & shipping
CountryContainer
(invokes)(returns)(returns)
TaxAndCountry container
(invokes)
(returns)
CountryToTaxCalculation
(returns)
CartCombinator
Supported rules so far
For(CANADA) take 0.15
For(UK) take 0.12 ignore shipping
For(USA) take 0.07 ignore discount &
shipping
“for Finland the tax is 5%, when calculating the tax ignore discount if shipping costs larger than 4”
Next up
Unsupported
adding a predicateclass CartCombinator(val cartField: CartField) {
}
def If (cond: CartPredicate): CartCombinator = new CartCombinator((c: Cart) => if (cond(c)) cartField(c) else 0)
def > (value: BigDecimal): CartPredicate = ((c: Cart) => cartField(c) > value)
. . .
adding a predicateclass CartCombinator(val cartField: CartField) {
def and (comb: CartCombinator): CartCombinator = new CartCombinator((c: Cart) => comb.cartField(c) + cartField(c))
def & (occ: CartCombinator): CartCombinator = and(occ)
def If (cond: CartPredicate): CartCombinator = new CartCombinator((c: Cart) => if (cond(c)) cartField(c) else 0)
def > (value: BigDecimal): CartPredicate = ((c: Cart) => cartField(c) > value)}
“for Finland the tax is 5%, when calculating the tax ignore discount if shipping costs larger than 4 ”
For(FINLAND).take(0.05).ignore (discount.If(shipping > 4))
For(FINLAND) take 0.05 ignore (discount If shipping > 4)
What's going on under the hood ?For(FINLAND) take 0.05 ignore (discount If shipping > 4)
(invokes)(returns)
CountryToTaxCalculation
CartCombinator
(returns)
CountryContainer
(invokes)(returns)
TaxAndCountry container
(returns)
Adding custom values
case class TaxAndCountry(country: Country, tax: BigDecimal) {
def addTouristPrice (f: Cart => BigDecimal): CountryToTaxCalculation = addCustomValue(f)
private def addCustomValue (f:CartField): CountryToTaxCalculation = (country, (cart: Cart) => (cart.total + f(cart)) * tax)
def ignore(fs: CartCombinator*): CountryToTaxCalculation = {
. . .
}
What's going on under the hood ?For(Israel) take 0.25 addTouristPrice { cart => cart.total * 0.2}
CountryContainer
(invokes)(returns)(returns)
TaxAndCountry container
(invokes) (returns)
CountryToTaxCalculation
Tax Rules DSL val taxRules: Seq[CountryToTax] = Seq(
For(CANADA) take 0.15 ,
For(UK) take 0.12 ignore shipping,
For(USA) take 0.07 ignore discount & shipping,
For(FINLAND) take 0.05 ignore (discount If shipping > 4),
For(Israel) take 0.25 addTouristPrice{ cart => cart.total *
0.2 }
)
English DSL
● “for Canada the tax is
15%"
For(CANADA) take 0.15
● “for UK the tax is 12%, when calculating the tax ignore the shipping cost”
For(UK) take 0.12 ignore shipping
● “for the USA the tax is 7%, ignore the shipping cost and the discount"
For(USA) take 0.07 ignore discount & shipping
● “for Finland the tax is 5% ignore discount if shipping costs larger than 4”
For(FIN) take 0.05 ignore (discount If shipping > 4)
● “for Israel the tax is 25%, add a special custom tourist tax just for fun”
For(Israel) take 0.25 addTouristPrice {cart => cart.total * 0.2}
Matching tax rule for a cartimport TaxDsl._
val taxRules: Seq[CountryToTax] val cart = Cart(total = 100, shipping = 10,discount = 20, country = USA)val taxRule = taxRules findRuleFor cart
To activate this we need to enrich an existing class
Enriching Seq implicit class TaxRuleSeq(taxRules: Seq[CountryToTaxCalculation]) { def findRuleFor(cart: Cart) = { taxRules.find(_._1 == cart.country).map(_._2) }}
Errors and exceptions●Always use the domain language
to express any exception that might occur during processing
●The compiler acts as the policeman for you
Final notes
●getting the syntax right is hard●A DSL needs only to be expressive enough for the user●DSLs are fun !
Q&AThis is where you are going to present your final words.This slide is not meant to have a lot of text.Thank You!Any Questions?Alon Muchnick