Skip to content
AgileLogics
7 min read

Building Domain-Specific Languages

Prerequisites: Builders, Closures in depth

A DSL (domain-specific language) is a small language for one job — a build, a pipeline, a configuration, a test. Groovy's optional parentheses, closures with delegates, and metaprogramming let you build DSLs that are still ordinary Groovy code.

Command chains

When parentheses are omitted, Groovy reads a b c d as a(b).c(d):

class Turtle {
    int x = 0, y = 0
    List<String> log = []

    Map move(String direction) {
        [by: { int steps ->
            switch (direction) {
                case 'left' -> x -= steps
                case 'right' -> x += steps
                case 'up' -> y += steps
                case 'down' -> y -= steps
            }
            log << "$direction $steps -> ($x, $y)"
            this
        }]
    }
}

def turtle = new Turtle()
turtle.with {
    move 'right' by 3
    move 'up' by 2
    move 'left' by 1
}
turtle.log.each { println it }
right 3 -> (3, 0)
up 2 -> (3, 2)
left 1 -> (2, 2)

move 'right' by 3 is move('right').by(3): move returns a map with a by closure, which is then called with 3.

Configuration DSLs

class ServerSpec {
    String host = 'localhost'
    int port = 80
    Map<String, String> routes = [:]

    void host(String h) { host = h }
    void port(int p) { port = p }
    void routes(@DelegatesTo(RouteSpec) Closure c) {
        def spec = new RouteSpec(routes: routes)
        c.delegate = spec
        c.resolveStrategy = Closure.DELEGATE_FIRST
        c()
    }
}

class RouteSpec {
    Map<String, String> routes
    void get(String path, String handler) { routes["GET $path"] = handler }
    void post(String path, String handler) { routes["POST $path"] = handler }
}

def server(@DelegatesTo(ServerSpec) Closure c) {
    def spec = new ServerSpec()
    c.delegate = spec
    c.resolveStrategy = Closure.DELEGATE_FIRST
    c()
    spec
}

def app = server {
    host 'api.example.com'
    port 8443
    routes {
        get '/users', 'UserController.list'
        post '/users', 'UserController.create'
    }
}

println "$app.host:$app.port"
app.routes.each { route, handler -> println "$route -> $handler" }
api.example.com:8443
GET /users -> UserController.list
POST /users -> UserController.create

This is the same pattern as Gradle's repositories { } and Jenkins' stages { } blocks. @DelegatesTo documents the delegate type, which gives IDE completion and allows @CompileStatic on DSL code.

Loading DSL files safely

Users write the DSL in a separate file; your program evaluates it with a custom base class:

import org.codehaus.groovy.control.CompilerConfiguration

abstract class AlertScript extends Script {
    List<String> rules = []
    void alert(Map args) { rules << "when ${args.when} notify ${args.notify}" }
}

def dsl = '''
    alert when: 'cpu > 90', notify: 'ops'
    alert when: 'disk < 5%', notify: 'oncall'
'''

def config = new CompilerConfiguration(scriptBaseClass: AlertScript.name)
def script = new GroovyShell(this.class.classLoader, config).parse(dsl)
script.run()
script.rules.each { println it }
when cpu > 90 notify ops
when disk < 5% notify oncall

With scriptBaseClass, DSL files can call alert directly. Combine this with a SecureASTCustomizer (see Embedding Groovy and Script Security) when the DSL comes from users you don't fully trust.

Practice

Extend the turtle so that move 'up' by 2 times 3 repeats a move three times.

Show solution
class Turtle {
    int x = 0, y = 0

    Map move(String direction) {
        [by: { int steps ->
            [times: { int n ->
                n.times {
                    if (direction == 'up') y += steps
                    if (direction == 'right') x += steps
                }
                this
            }]
        }]
    }
}

def t = new Turtle()
t.with {
    move 'up' by 2 times 3
    move 'right' by 1 times 4
}
println "(${t.x}, ${t.y})"
(4, 6)