groovy
Groovy everywhere, with one large asterisk

I did not set out to learn Groovy. I set out to make a Jenkins pipeline do
something, and a Jenkinsfile is Groovy, so I learned as much of it as the
error messages demanded and no more. Six weeks later I had a shared library
and a NotSerializableException I could not explain, which is the point at
which "as much as the error messages demand" stops being a strategy.
Groovy 4.0.0 went GA on the twenty-fifth of January, which is a good excuse to write down what I worked out. Including the part where the Groovy in a Jenkinsfile is a dialect from 2015 running through a rewriting compiler inside a whitelist.
What it is
A JVM language, first released in 2003, an Apache project since 2015.
groovyc compiles it to ordinary JVM bytecode — the .class files are
indistinguishable from Java's, and Java code can call into them without
knowing. Almost all valid Java is also valid Groovy, which is why the on-ramp
feels so short.
The pitch is: everything Java does, minus the ceremony, plus closures, plus a metaobject protocol that lets you bend the language into a domain-specific one.
class Agent {
String name
List<String> labels = []
}
def win = new Agent(name: 'win-build-01', labels: ['windows', 'signing'])
assert win.labels.any { it.startsWith('win') }
assert win.labels // a non-empty list is truthy
No public, no getters and setters you wrote, a map constructor you did not
write, it as the implicit closure parameter, and Groovy truth. That is the
entire appeal in six lines: it reads like the thing you meant.
How it runs
Three mechanics are worth understanding, because all three of them show up later as constraints.
A script is a class. Compile a file with bare statements at the top level
and you get a class extending groovy.lang.Script, with your statements in a
run() method:
$ echo 'println "hello"' > hi.groovy
$ groovyc hi.groovy && javap hi.class
public class hi extends groovy.lang.Script {
public hi();
public java.lang.Object run();
public static void main(java.lang.String...);
}
Variables declared without def or a type do not become locals — they go into
the script's Binding. That is exactly how Jenkins injects env, params
and currentBuild into your Jenkinsfile, and how Gradle injects project
into a build script. It is not magic; it is a Map the host filled in before
calling run().
Method calls go through a metaclass. thing.doIt() does not compile to a
direct virtual call on doIt. It goes to the object's MetaClass, which can
be modified at runtime. That indirection is what buys you methodMissing,
propertyMissing, category classes, and builders — it is why task hello { }
in a Gradle file reads like syntax when it is a method call the project object
handles dynamically.
Groovy 4 made that dispatch always go through invokedynamic; the separate
non-indy build variants are gone. That, and the move to the
org.apache.groovy group id, are the two changes most likely to affect you
before any of the new syntax does.
You can turn the dynamism off. @CompileStatic compiles a class with
Java-like static dispatch: real compile-time errors, performance close to
Java, and none of the metaprogramming.
import groovy.transform.CompileStatic
@CompileStatic
long freeBytes(List<Map<String, Object>> disks) {
disks.sum { (long) it.freeBytes } as long
// it.freBytes is now a compile error rather than a null at two in the morning
}
The pattern that works is static by default in anything that behaves like a library, dynamic only where the DSL actually lives. Which, on reflection, is the same advice as "keep the clever part small".
Where it genuinely is everywhere
Here is the thing I did not appreciate until I looked: I have been running Groovy for years without writing any.
Gradle. Every build.gradle in the Java world, which is most of the Java
world. On sheer executions this probably makes Groovy one of the most-run
languages on Earth.
Jenkins. Jenkinsfiles, shared libraries, the script console, Job DSL, and the Groovy hooks in Configuration as Code.
Spock. The nicest test DSL on the JVM, and it is Groovy that makes
given: / when: / then: / where: blocks possible rather than a comment
convention. Spock 2.0 moved onto the JUnit 5 platform last year, so it now
drops into an existing Java build without an argument.
Grails. Still shipping; version 5 landed in the autumn.
Anything with a scripting hook. Apache NiFi, SoapUI, Jira, Confluence, Bamboo, and a long tail of internal tools that needed "let the user write a bit of logic" and already had a JVM in the process.
The pattern is consistent, and it is not "people chose Groovy for their application". Groovy turns up as the configuration and glue layer over a system written in Java. It is the JVM's shell script. That is not a small role — it is a specific one, and being honest about it is more useful than the slogan.
The asterisk, and it is Jenkins
The Groovy in a Jenkinsfile is not the Groovy that shipped last month. Three separate reasons, and they compound.
It is Groovy 2.4. Jenkins core bundles a Groovy 2.4.x runtime and pipeline
execution uses it. No Parrot parser, which means none of Groovy 3's
syntax — no Java-style lambdas, no method references, no !in, no
do/while. And obviously nothing from 4. Copy a snippet off a 2022 blog
post that does agents.stream().map(Agent::getName) and it will not compile in
your pipeline, with an error that does not mention versions.
It is CPS-transformed. This is the big one. A pipeline has to survive the controller restarting halfway through a build, so the Pipeline: Groovy plugin rewrites your program into continuation-passing style: every call becomes a step in a state machine whose entire state is serialised to disk after each one. The consequences you will actually meet:
Every local variable that is live across a step must be Serializable. A
Matcher from =~, a File, an InputStream, a SimpleDateFormat — hold
one across a sh or a powershell and you get a NotSerializableException
naming a class you did not think you were storing.
// Fails on the powershell step: the Matcher is a live local.
def m = readFile('version.txt') =~ /(\d+)\.(\d+)/
powershell 'ci\\build.ps1'
echo m[0][1]
// Fine. The Matcher never crosses a step boundary.
@NonCPS
String major(String text) {
def m = text =~ /(\d+)\.(\d+)/
return m ? m[0][1] : null
}
Groovy's own iteration methods are not transformed, so a closure body inside
.each { } does not behave quite like the body of a for loop when it calls
steps. java.util.stream does not work at all, because the JDK is not
transformed and your closure is. And the escape hatch, @NonCPS, runs a
method as plain Groovy — which means no pipeline steps inside it, and whatever
it returns had better be serialisable.
It is sandboxed. Groovy script security intercepts every method call and
checks it against a whitelist. new File(...), Jenkins.instance,
System.getenv() — blocked, pending an administrator approving it in
In-process Script Approval.
That is correct behaviour, and it is worth saying so plainly: a Jenkinsfile is code from a repository, executed on the controller, and an unsandboxed Jenkinsfile is remote code execution against your CI. It also means that a large share of the Groovy you find online is script-console Groovy, written by an administrator with no sandbox, and it will never run in a pipeline.
Put the three together and the practical rule falls out on its own. A Jenkinsfile is a declarative document with a small amount of orchestration in it. When I want to write a program, I write it in a language the machine is going to run normally — which by this month means Python, for the reasons I went through a fortnight ago.
Where else "everywhere" runs out
Native images. GraalVM's native-image needs to know at build time what
can be called. A runtime metaobject protocol is close to the opposite of that.
@CompileStatic on everything helps and it is not a solved problem.
Android. There was a serious effort to make Groovy work there. The dynamic runtime and the DEX toolchain never came to terms.
Startup. JVM start plus Groovy's runtime initialisation is a good fraction of a second before your first line executes. Invisible inside the Gradle daemon. Very visible in a CLI you call in a loop, which is precisely the shape of most automation.
Tooling. Gradle's Kotlin DSL exists because an IDE can complete Kotlin and cannot reliably complete a dynamic Groovy DSL. That is a real structural disadvantage, not a fashion, and it is the same trade in a different suit: everything the metaclass gives you at runtime, it takes away from anything trying to reason about your code beforehand.
So: everywhere?
Groovy runs anywhere a JVM runs, and it is present in far more places than its reputation suggests. But "Groovy everywhere" is true the way "bash everywhere" is true. It is the language you find at the seams of systems built in something else, doing configuration, glue and orchestration, and it is genuinely excellent at that.
Jenkins is where somebody asked it to do more than that, and the result is a dialect frozen at 2.4, compiled through a rewriter, executed behind a whitelist, in which a regex match is a bug waiting for the next step.
Learn enough to read a build.gradle and write a clean Jenkinsfile. It is
about a weekend, and it will pay for itself the first time an error message
mentions CpsCallableInvocation. Then keep your logic somewhere you can test
it.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


