I’ve always liked DSLs, but building one turned out to be more humbling than I expected. ScalaCron was my first real attempt at designing an embedded DSL, and while it works, it also taught me a few lessons the hard way.
Some months I decided to pick up Scala again and make some projects. One of them was my first embedded DSL experiment : ScalaCron. A DSL to generate cronjob expressions programatically.
Just in case you aren't familiar with the term, a Domain Specific Language is a language used for very specific fields. It attempts to solve the complexity of a language by removing imperative programming in favor of a more declarative approach. It has a syntax that tells what will be done instead of how to do it. There are two types: external languages and non-external. The difference is that the former runs on top of a host language and the latter does not. Some popular DSLs of both types are: SQL, Regex, and Makefiles (external), and LINQ, Scala collections, and Gradle Kotlin DSL (non-external).
Scala syntax and abstractions make writing DSLs relatively easy.
DSL development is a two layer architecture design. I knew this before, but it hit me harder a bit too late, perhaps.
What are these two layers?
1.- Domain modeling.
2.- Syntax and interpretation layer.
Designing the domain was relatively easy, but soon enough I faced a point at which, even though I had a safe model layer, it made it difficult to make the syntax more English-like.
I had this method for expressions to validate their values:
final def applyValidated(value: A): Either[CronError, CronExpr[A]] =
if range.contains(value) && pf.isDefinedAt(value) && pf(value) then
Right(At(build(value)))
else
Left(InvalidRange(value, range.end))
At the time it worked just fine, but when I was moving on to making the syntax prettier, I found that the handling of errors leaked through and made abstracting things harder (not impossible, but syntactically ugly).
At some point I had something like:
import CronDSL.*
val job = CronJobExpr.build { c =>
c.m = * / 5
c.h = 12.h
c.dom = 1.dom
c.dow = 1.dow
}
println(job) //*/5 12 1 1
which needed a builder and I didn't like it.
After applying some abstractions and making tradeoffs for keeping my sanity without refactor, I decided to move validation to the interpretation layer:
private def validateExpr(expr: CronExpr[Int]): List[CronError] =
expr match {
case At(v) =>
validateValue(v)
case Range(from, to) =>
validateValue(from) ++
validateValue(to) ++
validateRangeOrder(from, to)
case ListExpr(values) =>
values.flatMap(validateValue)
case Every(step, field) =>
val stepErrors =
if (step <= 0) List(InvalidStep(step)) else Nil
stepErrors
case Step(from, step) =>
val stepErrors =
if (step <= 0) List(InvalidStep(step)) else Nil
validateValue(from) ++ stepErrors
}
Granted it made things easier, but I still feel a bit guilty that validation was delayed this way.
In the end, I decided not to refactor as this was just an experiment and the syntax was just good enough
import domain.Models.*
import domain.Models.Minute.given
import dsl.CronDSL.{to, *}
import Days.*
import scala.language.postfixOps
object Main :
def main(args: Array[String]): Unit =
val job2 = cron { c =>
c.minute(* / 5)
c.hour(2.h)
c.dom(1.dom)
c.dow(Friday)
} >> "/usr/bin/backup.sh"
println(job2?) //same as job.schedule
println(job2) //whole expression as string
/*
- Minute : every 5 minutes
- Hour : at 2
- Day of Month : at 1
- Day of Week : Friday
*/5 2 1 5 /usr/bin/backup.sh
*/
In an upcoming post, I’ll walk through a second DSL where I applied these lessons from the start, especially around validation, layering, and syntax design and the results were noticeably better.
Top comments (0)