Domain Specific Languages

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). Groovy is a popular host for internal DSLs: a small language embedded in Groovy syntax, written by the API author and read by someone who is not necessarily a programmer, such as a grower who wants to say "water the tomatoes every 2 hours for 10 minutes". Nothing here is special syntax. A DSL is built from four ordinary language features that this page ties together: the command chain (method calls without parentheses or dots), operator overloading and properties added to numbers (so values read as 2.hours), script base classes (so a script file sees the DSL vocabulary), and closures with a controlled delegate (so nested blocks resolve names against a builder). The last section combines all of them into one working greenhouse watering DSL.

The pieces are explained in more detail elsewhere: Closure Delegation: Owner, Delegate & This covers how a closure resolves names, Operator Overloading lists the operator-to-method table, Builders shows the ready-made builders, and Compilation Customizers & Secure Scripting covers restricting what a DSL script is allowed to do. The same idea in Kotlin is described on Kotlin Type-Safe Builders & DSLs.

Command chains

Groovy lets you leave out the parentheses of a method call used as a statement. A command chain extends that: a sequence of parenthesis-free calls needs neither parentheses around the arguments nor dots between the calls. In general a b c d is equivalent to a(b).c(d): words alternate between a method name and its argument, and each call is made on the result of the previous one. The chain also works with several arguments, closures and named arguments, and may appear on the right-hand side of an assignment.

log = []                                  // a binding variable, visible inside the script's methods
def check(Map m) { log << "check($m)"; [tastes: { x -> log << "tastes($x)" }] }
def given(Closure c) { [when: { Closure w -> [then: { Closure t -> log << 'given/when/then' }] }] }
def paint(w) { [with: { Object[] cs -> [and: { y -> log << "paint($w).with(${cs.toList()}).and($y)" }] }] }
def select(all) { [unique: { -> [from: { names -> log << "select($all).unique().from($names)" }] }] }
def take(n) { new Object() { def getCookies() { "$n cookies" } } }

check that: 'margarita' tastes 'good'          // check(that: 'margarita').tastes('good')
given { } when { } then { }                     // given({}).when({}).then({})
paint 'wall' with 'red', 'green' and 'yellow'   // paint('wall').with('red', 'green').and('yellow')
select 'all' unique() from 'names'              // a no-argument call keeps its parentheses
def c = take 3 cookies                          // odd count: take(3).cookies, a property access
log.each { println it }
println c
check([that:margarita])
tastes(good)
given/when/then
paint(wall).with([red, green]).and(yellow)
select(all).unique().from(names)
3 cookies

The rules the example shows:

  • Words alternate: method name, argument, method name, argument. An argument list is separated by commas, so with 'red', 'green' and 'yellow' passes two arguments to with and one to and.

  • A method that takes no argument must keep its parentheses, as in unique(), otherwise the word would be read as the argument of the previous call.

  • With an odd number of words, the last word is a property access on the result: take 3 cookies is take(3).cookies, which calls getCookies().

  • Each link in the chain is looked up dynamically on whatever the previous call returned. Any object with the right methods works: a map of closures (the quickest way to prototype), a small class, or a builder.

Three limits show up the first time you write a chain. The first is that a chain is a statement (or the right-hand side of an assignment), so it cannot be written inside the parentheses of another call. The second is that a Groovy keyword cannot be used as a link in the chain. The third is that a closure directly after a string literal is read as a method call on that name.

def probe(a) { [then: { b -> [size: 42] }] }
def n = probe 1 then 2 size       // fine: assigned to a variable
println n
// println(probe 1 then 2 size)    // does not compile: Missing ')'
// pump 'north' at 6 for 10       // does not compile: Unexpected input: 'for'
// zone 'north' { ... }           // calls a method named north(Closure); write zone('north') { ... }
42

The two commented lines were tried separately and failed with exactly the messages quoted in the comments. The greenhouse DSL later on therefore uses lasting where a natural English sentence would say "for", and it opens zones with zone('north') { ... }.

A chain built from maps and closures

The shortest way to build a chain is a method that returns a map whose values are closures, each of which returns the next map. This is the example from the Groovy documentation, followed by a variation that wraps an existing API with a more readable surface.

show = { println it }
square_root = { Math.sqrt(it) }
def please(action) {
  [the: { what ->
    [of: { n -> action(what(n)) }]
  }]
}
please show the square_root of 100       // please(show).the(square_root).of(100)

def split(String s) {
  [on: { sep ->
    [trimming: { ch -> s.split(sep).collect { it.trim().replaceAll("^${ch}+|${ch}+\$", '') } }]
  }]
}
def parts = split "_a ,_b_ ,c__" on ',' trimming '_'
println parts
10.0
[a, b, c]

How water 'tomatoes' every 2.hours is parsed

The figure below follows the greenhouse sentence used for the rest of the page. The parser sees four tokens, pairs them into a chain of two calls, and evaluates the argument of the outer call (a property access on the literal 2) before that call is made.

flowchart TD S["Source text: water 'tomatoes' every 2.hours"] --> T["Tokens: water, 'tomatoes', every, 2.hours"] T --> P["Command chain: method name, argument, method name, argument"] P --> C1["First call: this.water('tomatoes')"] P --> A2["Argument of the second call: 2.hours"] A2 --> PA["Property access on the literal 2"] PA --> G["Category lookup: getHours(2) returns a Span"] C1 --> R1["Returns an object with an every method"] R1 --> C2["Second call: result.every(span)"] G --> C2 C2 --> R2["Returns the next link of the chain, or the final value"]

The same shape can be read from the compiler. The script below asks the compiler for the syntax tree of that one line (Phases.CONVERSION is early enough that nothing has been resolved) and prints its method calls and property accesses.

import org.codehaus.groovy.ast.expr.*
import org.codehaus.groovy.control.*

def cu = new CompilationUnit()
cu.addSource('t.groovy', "water 'tomatoes' every 2.hours")
cu.compile(Phases.CONVERSION)
def stmt = cu.ast.modules[0].statementBlock.statements[0]

def show
show = { Expression e, int d = 0 ->
    def pad = '  ' * d
    switch (e) {
        case MethodCallExpression:
            println "${pad}MethodCall ${e.methodAsString}"
            println "${pad}  receiver:"; show(e.objectExpression, d + 2)
            println "${pad}  args:"; e.arguments.expressions.each { show(it, d + 2) }; break
        case PropertyExpression:
            println "${pad}Property .${e.propertyAsString}"; show(e.objectExpression, d + 1); break
        case ConstantExpression: println "${pad}Constant ${e.value.inspect()}"; break
        case VariableExpression: println "${pad}Variable ${e.name}"; break
        default: println "${pad}${e.class.simpleName} ${e.text}"
    }
}
show(stmt.expression)
MethodCall every
  receiver:
    MethodCall water
      receiver:
        Variable this
      args:
        Constant 'tomatoes'
  args:
    Property .hours
      Constant 2

every is called on the result of water, which is called on this, and the only argument of every is the property hours of the constant 2. There is no special DSL node: the chain is plain nested method calls.

Operator overloading

Most Groovy operators call a method with a fixed name, so any class can take part in them: a + b calls plus, a * b calls multiply, a << b calls leftShift, -a calls negative, a <=> b calls compareTo, and if (a) calls asBoolean. The full table is on Operator Overloading. For a DSL this is how quantities, levels and rules get arithmetic that reads like the domain.

class Level {
    int pct
    Level plus(Level o) { new Level(pct: pct + o.pct) }
    Level negative()    { new Level(pct: -pct) }
    boolean asBoolean() { pct > 0 }
    String toString()   { "${pct}%" }
}
def a = new Level(pct: 30), b = new Level(pct: 25)
println a + b
println(-a)
println(b ? 'wet' : 'dry')
55%
-30%
wet

Adding properties to numbers

Numbers are objects like any other, so a DSL can give them properties and methods: 2.hours is the property access 2.getHours(), with getHours supplied from outside the Integer class. Groovy has three ways to do that, with different scoping.

  • A category is a class of static methods whose first parameter is the receiver. use(TheCategory) { ... } makes them visible in the closure and in everything it calls on the same thread, and only there. Categories are lexically bound, which is why they are a good fit for DSLs: the vocabulary exists exactly where the DSL is being evaluated.

  • ExpandoMetaClass (Number.metaClass.getLitres = { -> delegate * 1.0 }) adds the property globally for the whole JVM until it is removed. See Metaclasses, ExpandoMetaClass & Categories.

  • An extension module adds the methods to the class permanently and, unlike the other two, is understood by the static compiler and type checker. See Extension Modules.

Groovy itself ships a category for time, groovy.time.TimeCategory:

import groovy.time.TimeCategory

use(TimeCategory) {
    def d = 2.hours + 30.minutes
    println d
    println d.class.name
    println 1.minute.from.now > new Date()
}

Number.metaClass.getLitres = { -> delegate * 1.0 }     // global alternative
println 5.litres
Number.metaClass = null                                 // and remove it again
2 hours, 30 minutes
groovy.time.TimeDuration
true
5.0

Script base classes

Groovy scripts are compiled to classes. A script such as println 'hello' becomes a class that extends groovy.lang.Script, whose abstract run method holds the script body, and every other method defined in the script file becomes a method of that class. Script also connects the script to its caller through a Binding: unresolved variable names are looked up in, and assigned into, the binding.

def binding = new Binding()
def shell = new GroovyShell(binding)
binding.setVariable('x', 1)
binding.setVariable('y', 3)
shell.evaluate 'z = 2 * x + y'
assert binding.getVariable('z') == 5

A binding passes data. To give scripts behaviour (the DSL vocabulary), supply your own base class. It must extend Script and have a single abstract method. It can be installed in the CompilerConfiguration for every script a GroovyShell compiles, or chosen by the script itself with @BaseScript.

import org.codehaus.groovy.control.CompilerConfiguration
import groovy.transform.BaseScript

abstract class MyBaseClass extends Script {
    String name
    void greet() { println "Hello, $name!" }
}

def config = new CompilerConfiguration(scriptBaseClass: 'MyBaseClass')
new GroovyShell(this.class.classLoader, config).evaluate '''
    setName 'Judith'
    greet()
'''
Hello, Judith!

Inside the script setName 'Judith' is the setter of the inherited name property, and greet() is the inherited method. Both were resolved against the base class, not the binding.

@BaseScript does the same from inside the script: annotate a variable declaration whose type is the base class, or give the class as the annotation value on an import. A script’s run method can also be annotated, which is handy when the file is a script with an explicit run.

@BaseScript(GreenhouseScript)
import groovy.transform.BaseScript

water 'peppers' every 6.hours lasting 4.minutes
println schedule.rules

(GreenhouseScript is defined in the greenhouse example further down.)

Alternate abstract method

The abstract method does not have to be run. A base class can implement run itself, doing set-up or tear-down around the script, and declare one other abstract method, which the compiler fills with the script body.

import org.codehaus.groovy.control.CompilerConfiguration

abstract class CountingBase extends Script {
    int count
    abstract void scriptBody()                 // the script body becomes this method
    def run() {
        count++                                 // work done before the body
        scriptBody()
        count                                   // the evaluation result is no longer the last expression
    }
}

def shell = new GroovyShell(this.class.classLoader, new CompilerConfiguration(scriptBaseClass: 'CountingBase'))
def result = shell.evaluate("println 'Ok'")
assert result == 1

def script = shell.parse("println 'Ok'")
assert script.run() == 1
assert script.run() == 2                      // the same script instance is run twice
Ok
Ok
Ok

Prefer this form for DSL base classes, because the base class is the only place that decides how the body runs: it can wrap the body in use(SomeCategory) { ... }, in a transaction, or in a time-out. The greenhouse DSL below does the first of those.

@DelegatesTo

A builder-style DSL uses nested closures whose names are resolved against a helper object, the delegate:

email {
    from 'dsl-guru@example.test'
    to 'john.doe@example.test'
    subject 'The pope has resigned!'
    body {
        p 'Really, the pope has resigned!'
    }
}

The method behind email creates a spec object, copies the closure with rehydrate(delegate, owner, thisObject) so that the delegate is that spec, switches the resolve strategy to Closure.DELEGATE_ONLY so that names are looked up only on the spec, and calls it. All of that is explained on Closure Delegation: Owner, Delegate & This. What it cannot do by itself is tell a reader, an IDE or the type checker what the delegate is. Under @TypeChecked every method call inside the closure fails, because the checker only sees the enclosing class.

groovy.lang.DelegatesTo (Groovy 2.1 and later) is a documentation and compile-time annotation on the closure parameter. It states which type the delegate has and which strategy is used, so IDEs can complete names and the type checker can verify them.

Simple delegation and a strategy

The only mandatory member is the delegate type in value. Add strategy when the closure is not run with the default Closure.OWNER_FIRST.

void body(@DelegatesTo(BodySpec) Closure cl) { /* delegate is a BodySpec, default strategy */ }

void body(@DelegatesTo(strategy = Closure.DELEGATE_ONLY, value = BodySpec) Closure cl) { /* DELEGATE_ONLY */ }

A working version, used by the greenhouse model below, makes the nested-zone example type check. Without the annotation the type checker reports one error per call in the block (this was tried with the same code minus the annotations, and gave 10 errors such as Cannot find matching method zones#hours(int)). With it, the DSL compiles and runs under @TypeChecked.

import groovy.transform.TypeChecked

class ZoneSpec {
    final String name
    final List<String> lines = []
    ZoneSpec(String name) { this.name = name }

    long hours(int n)   { n * 3600L }
    long minutes(int n) { n * 60L }
    void water(String crop, long every, long lasting) {
        lines << "${crop}: every ${every.intdiv(3600)}h for ${lasting.intdiv(60)}m"
    }
}

class GreenhouseSpec {
    final List<ZoneSpec> zones = []
    void zone(String name,
              @DelegatesTo(value = ZoneSpec, strategy = Closure.DELEGATE_ONLY) Closure body) {
        def spec = new ZoneSpec(name)
        def code = body.rehydrate(spec, this, this)
        code.resolveStrategy = Closure.DELEGATE_ONLY
        code()
        zones << spec
    }
}

GreenhouseSpec greenhouse(@DelegatesTo(value = GreenhouseSpec, strategy = Closure.DELEGATE_ONLY) Closure body) {
    def spec = new GreenhouseSpec()
    def code = body.rehydrate(spec, this, this)
    code.resolveStrategy = Closure.DELEGATE_ONLY
    code()
    spec
}

@TypeChecked
void build() {
    def g = greenhouse {
        zone('north') {
            water 'tomatoes', hours(2), minutes(10)
            water 'peppers', hours(6), minutes(4)
        }
        zone('south') {
            water 'lettuce', hours(24), minutes(15)
        }
    }
    g.zones.each { z -> println "${z.name} -> ${z.lines}" }
}
build()
north -> [tomatoes: every 2h for 10m, peppers: every 6h for 4m]
south -> [lettuce: every 24h for 15m]

Delegating to another parameter

Sometimes the delegate is not created inside the method but passed in. Annotate that parameter with @DelegatesTo.Target and the closure with a bare @DelegatesTo. The delegate type is then taken from the argument at each call site, so one exec serves every receiver type and the type checker still verifies the block. The annotation also accepts strategy.

Several closures and several targets

When a method takes several closures and several delegates, give each target a name and point each closure at one: @DelegatesTo.Target('pump') on the parameter and @DelegatesTo(target = 'pump') on the closure. Parameter names are not used because they are not always present in compiled code.

Delegating to a generic type

For configureAll(List<T> items, Closure config) the delegate is the element type. genericTypeIndex says which generic argument of the @DelegatesTo.Target parameter to use (counting from 0), and the checker infers it from the call site.

Delegating to an arbitrary type

When the delegate type appears nowhere in the method signature, use the type member with a type token, as in @DelegatesTo(type = 'T') for a mapper that delegates to its stored value. Use it as a last resort: the token is checked only where the annotated method is called, not where it is declared, and compilation is slightly slower.

The four modes, run in one file under @CompileStatic:

import groovy.transform.CompileStatic

class Sprinkler { void spray(String zone) { println "spraying $zone" } }
class Sensor    { int moisture() { 37 } }
class Pump      { void start(int minutes) { println "pump on for $minutes min" } }
class Bed       { String crop; int litres }

// delegate to another parameter
def exec(@DelegatesTo.Target Object target, @DelegatesTo Closure code) {
    code.rehydrate(target, this, this).call()
}

// several closures, several targets
void irrigate(
        @DelegatesTo.Target('sprinkler') sprinkler,
        @DelegatesTo.Target('sensor')    sensor,
        @DelegatesTo.Target('pump')      pump,
        @DelegatesTo(target = 'sprinkler') Closure cl1,
        @DelegatesTo(target = 'sensor')    Closure cl2,
        @DelegatesTo(target = 'pump')      Closure cl3) {
    cl1.rehydrate(sprinkler, this, this).call()
    println "moisture: ${cl2.rehydrate(sensor, this, this).call()}%"
    cl3.rehydrate(pump, this, this).call()
}

// generic type of a parameter
public <T> void configureAll(@DelegatesTo.Target List<T> items,
        @DelegatesTo(strategy = Closure.DELEGATE_FIRST, genericTypeIndex = 0) Closure config) {
    items.each { e ->
        def clone = config.rehydrate(e, this, this)
        clone.resolveStrategy = Closure.DELEGATE_FIRST
        clone.call()
    }
}

// an arbitrary type
class Mapper<T, U> {
    final T value
    Mapper(T value) { this.value = value }
    U map(@DelegatesTo(type = 'T') Closure<U> producer) {
        producer.delegate = value
        producer()
    }
}

@CompileStatic
void typed() {
    exec(new Sprinkler()) { spray 'north' }

    irrigate(new Sprinkler(), new Sensor(), new Pump(),
        { spray 'south' },
        { moisture() },
        { start 5 })

    List<Bed> beds = [new Bed(), new Bed()]
    configureAll(beds) { crop = 'basil'; litres = 4 }
    assert beds.every { it.crop == 'basil' && it.litres == 4 }
    println beds*.crop

    def mapper = new Mapper<String, Integer>('tomatoes')
    assert mapper.map { length() } == 8
    println mapper.map { length() }
}
typed()
spraying north
spraying south
moisture: 37%
pump on for 5 min
[basil, basil]
8

Remove @DelegatesTo(type = 'T') from Mapper.map and the same @CompileStatic code fails to compile with Cannot find matching method ...#length(), which is the error the annotation exists to remove.

Type-checked DSLs

A DSL written as a script is dynamic by default: a misspelt word is found when that line runs, perhaps in front of the user. Because @DelegatesTo gives the type checker the delegate type, a DSL script can instead be compiled with @TypeChecked, and the typo is found before anything runs. A script cannot carry the annotation on its implicit class, so a compilation customizer applies it to every script the shell compiles (see Compilation Customizers & Secure Scripting). Here the ZoneSpec and GreenhouseSpec classes from above are loaded and a script with a typo is compiled:

import groovy.transform.TypeChecked
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.MultipleCompilationErrorsException
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer

def config = new CompilerConfiguration()
config.addCompilationCustomizers(new ASTTransformationCustomizer(TypeChecked))
def shell = new GroovyShell(this.class.classLoader, config)

def dsl = '''
greenhouse {
    zone('north') {
        wter 'tomatoes', hours(2), minutes(10)     // typo
    }
}
'''
try {
    shell.parse(modelClassesSource + dsl)      // modelClassesSource: the classes and greenhouse() from above
} catch (MultipleCompilationErrorsException e) {
    println e.errorCollector.getError(0).cause.message.readLines()[0]
}
[Static type checking] - Cannot find matching method Script1#wter(java.lang.String, long, long). Please check if the declared type is correct and if the method exists.

The error names the unknown method and its argument types. It names the script class (Script1) rather than ZoneSpec as the receiver, which is how the checker reports a miss inside a delegated closure.

Type checking has a price: the checker does not know about categories or ExpandoMetaClass. A statically checked DSL therefore cannot rely on 2.hours supplied by use(SpanCategory); it uses plain methods (hours(2) above), or puts the property on the number with an extension module, which the checker does understand. For checker extensions that teach it about a DSL’s own rules, see Type Checking Extensions, and for the options of the checker itself Static Type Checking & @CompileStatic.

The greenhouse watering DSL, end to end

This section builds the sentence water 'tomatoes' every 2.hours lasting 10.minutes as a real, runnable language and loads schedules from a file. It is a variation of the sentence water 'tomatoes' every 2.hours at 6.am used on Optionality: a duration rather than a start time, because the schedule needs it to total the daily watering time. The design has four small parts and a runner.

  • Span is a value type for a length of time, with the operators +, * and <=>.

  • SpanCategory adds 2.hours, 10.minutes, 1.day and 3 * span to numbers, only inside use(SpanCategory) { }.

  • Schedule and WateringRule are the model the DSL produces.

  • GreenhouseScript is the script base class: it provides the water command chain, and uses the alternate abstract method so that every script body runs inside use(SpanCategory).

All files live in one directory and are run with groovy -cp . Run.groovy.

The value type: operator overloading

// Span.groovy
import groovy.transform.Immutable

/** A length of time, with the operators a DSL needs. */
@Immutable
class Span implements Comparable<Span> {
    long seconds

    Span plus(Span other)      { new Span(seconds + other.seconds) }
    Span multiply(int times)   { new Span(seconds * times) }
    int compareTo(Span other)  { seconds <=> other.seconds }

    String toString() {
        long h = seconds.intdiv(3600), m = (seconds % 3600).intdiv(60), s = seconds % 60
        [h ? "${h}h" : null, m ? "${m}m" : null, s ? "${s}s" : null].findAll().join(' ') ?: '0s'
    }
}

plus makes 1.hour + 30.minutes work, multiply makes span * 3 work, and implementing Comparable gives the relational operators for free (<, >, <= and >= call compareTo).

Properties on numbers: a category

// SpanCategory.groovy
/** Adds 2.hours, 10.minutes and 3 * span to numbers, but only inside use(SpanCategory) { } */
class SpanCategory {
    static Span getSeconds(Number n) { new Span(n.longValue()) }
    static Span getMinutes(Number n) { new Span(n.longValue() * 60) }
    static Span getHours(Number n)   { new Span(n.longValue() * 3600) }
    static Span getDays(Number n)    { new Span(n.longValue() * 86400) }

    // singular spellings read better after a 1
    static Span getMinute(Number n)  { getMinutes(n) }
    static Span getHour(Number n)    { getHours(n) }
    static Span getDay(Number n)     { getDays(n) }

    static Span multiply(Number n, Span s) { s * n.intValue() }   // 3 * 20.minutes
}

Each static method takes the receiver as its first parameter. A method named getHours is a property hours, and the last method gives Number a multiply that accepts a Span, so 3 * 5.minutes works with the number on the left, which operator overloading on Span alone could not do.

The model

// Schedule.groovy
class WateringRule {
    String crop
    Span every
    Span lasting
    String toString() { "water ${crop} every ${every} for ${lasting}" }
}

class Schedule {
    final List<WateringRule> rules = []
    Schedule leftShift(WateringRule rule) { rules << rule; this }   // schedule << rule
    Span waterTimePerDay() {
        rules.inject(new Span(0)) { acc, r -> acc + r.lasting * (86400L.intdiv(r.every.seconds) as int) }
    }
}

leftShift is the method behind <<, so the base class below can write schedule << rule.

The script base class

// GreenhouseScript.groovy
abstract class GreenhouseScript extends Script {
    Schedule schedule = new Schedule()

    abstract void scriptBody()                       // the user's script becomes this method

    def run() {
        use(SpanCategory) { scriptBody() }          // 2.hours only works while the script body runs
        schedule
    }

    // water 'tomatoes' every 2.hours lasting 10.minutes
    //   is water('tomatoes').every(2.hours).lasting(10.minutes)
    def water(String crop) {
        def rule = new WateringRule(crop: crop)
        schedule << rule
        [every: { Span interval ->
            rule.every = interval
            [lasting: { Span duration -> rule.lasting = duration; rule }]
        }]
    }
}

Three decisions are worth noting. run is implemented by the base class, so the user’s file cannot forget to install the category. water returns a map of closures, the chain technique from the first section, and registers the rule as soon as it starts, so the chain’s return value does not matter. And the argument types of every and lasting are Span: writing every 2 gives a clear MissingMethodException-style failure instead of a rule with a nonsense interval.

The DSL file and the runner

// plan.greenhouse
water 'tomatoes' every 2.hours lasting 10.minutes
water 'lettuce' every 1.day lasting 3 * 5.minutes
water 'basil' every 12.hours lasting 90.seconds + 30.seconds
// Run.groovy
import org.codehaus.groovy.control.CompilerConfiguration

def config = new CompilerConfiguration(scriptBaseClass: 'GreenhouseScript')
def shell  = new GroovyShell(this.class.classLoader, config)

def schedule = shell.evaluate(new File('plan.greenhouse'))   // evaluate returns what run() returns
schedule.rules.each { println it }
println "water per day: ${schedule.waterTimePerDay()}"
assert schedule.rules*.crop == ['tomatoes', 'lettuce', 'basil']

// outside the category the number properties do not exist
try {
    2.hours
} catch (MissingPropertyException e) {
    println "outside use(SpanCategory): ${e.message.readLines()[0]}"
}
water tomatoes every 2h for 10m
water lettuce every 24h for 15m
water basil every 12h for 2m
water per day: 2h 19m
outside use(SpanCategory): No such property: hours for class: java.lang.Integer

The total is checked by hand: tomatoes run 12 times a day for 10 minutes (120 minutes), lettuce once for 15, basil twice for 2, which is 139 minutes or 2 h 19 m. The .greenhouse file has no imports, no def and no parentheses, yet it is plain Groovy compiled against GreenhouseScript. The same file works with @BaseScript at its top, run directly with groovy -cp . peppers.groovy, as in the earlier snippet, which printed [water peppers every 6h for 4m].

Making it safer and nestable

The version above is deliberately small. A production DSL usually adds three things, all covered by pieces seen earlier:

  • Nested blocks such as zone('north') { water ... }, written with a @DelegatesTo(value = ZoneSpec, strategy = Closure.DELEGATE_ONLY) closure parameter as in the type-checked section, so a zone block cannot reach the script’s fields by accident.

  • A restriction on what a user script may do, so that a .greenhouse file cannot call System.exit or read files. That is a job for SecureASTCustomizer and an import customizer, described on Compilation Customizers & Secure Scripting.

  • Hosting the shell inside a Java application and reloading a changed schedule, described on Integrating Groovy into Java Applications.

Choosing the techniques

Technique Use it for Limits

Command chain with maps of closures

Sentence-like statements; quick to write

Not checked at compile time; a chain is a statement; keywords and name { } after a string are read differently

Operator overloading

Quantities, levels, rules with arithmetic

Only the fixed operator set; types decide dispatch

Category (use)

Properties on numbers or strings, scoped to a block

Thread-bound and lexical; invisible to @CompileStatic and @TypeChecked

ExpandoMetaClass

Adding properties everywhere, quickly

Global for the JVM, so tests and other code see it

Extension module

Permanent additions that the static compiler understands

Needs a module descriptor on the classpath

Script base class and @BaseScript

A vocabulary and lifecycle for script files

One base class per script; the body must be a single abstract method type

@DelegatesTo

Nested builder blocks with IDE help and type checking

Annotation only describes the delegate; the code must still set it

Where the book is outdated

The Manning book Groovy in Action (publisher page in the references) discusses DSLs mostly as a matter of syntax and closures. Check these against the current documentation:

  • @DelegatesTo was introduced in Groovy 2.1, so a text written before it describes DSLs without compile-time delegate information and without type-checked closures. @BaseScript and the alternate abstract method are likewise later additions; check the release notes of each version for the exact release.

  • Command chains in the book’s era were limited; today a b c d is a(b).c(d) with named arguments, closures and comma-separated arguments, and works on the right-hand side of an assignment.

  • Security Manager based sandboxing is no longer an option in Groovy 6.0, so a DSL meant to run untrusted scripts must restrict them at the AST level and with process or container isolation. See the compilation customizers page.

  • Groovy 6.0 needs JDK 17 or later.