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 towithand one toand. -
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 cookiesistake(3).cookies, which callsgetCookies(). -
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.
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.
-
Spanis a value type for a length of time, with the operators+,*and<=>. -
SpanCategoryadds2.hours,10.minutes,1.dayand3 * spanto numbers, only insideuse(SpanCategory) { }. -
ScheduleandWateringRuleare the model the DSL produces. -
GreenhouseScriptis the script base class: it provides thewatercommand chain, and uses the alternate abstract method so that every script body runs insideuse(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
.greenhousefile cannot callSystem.exitor read files. That is a job forSecureASTCustomizerand 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 |
Operator overloading |
Quantities, levels, rules with arithmetic |
Only the fixed operator set; types decide dispatch |
Category ( |
Properties on numbers or strings, scoped to a block |
Thread-bound and lexical; invisible to |
|
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 |
A vocabulary and lifecycle for script files |
One base class per script; the body must be a single abstract method type |
|
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:
-
@DelegatesTowas introduced in Groovy 2.1, so a text written before it describes DSLs without compile-time delegate information and without type-checked closures.@BaseScriptand 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 disa(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.