DEV Community

Cover image for Building My First Scala DSL (and the Design Mistakes I Made)
AranaDeDoros
AranaDeDoros

Posted on

Building My First Scala DSL (and the Design Mistakes I Made)

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
      }
Enter fullscreen mode Exit fullscreen mode

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
*/

Enter fullscreen mode Exit fullscreen mode

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.

ScalaCron repo

Top comments (0)