
Why `BackgroundJob.enqueue(() -> ...)` does not compile to what you think it does in Groovy, and what to do about it
Groovy folks, this one is a bytecode story that came out of writing a Grails + JobRunr integration guide. I think it is interesting even if you never touch background jobs, because it is a clean example of where Groovy closures and Java SAM lambdas diverge.
The setup
JobRunr is an open-source background job library for Java. Its headline API takes a lambda:
BackgroundJob.enqueue(() -> myService.processOrder(orderId));
The trick under the hood: JobRunr uses ASM to walk the lambda's bytecode, find the method invocation inside it, capture the argument values, and persist that as a serialisable job description. When a worker picks the job up later, it reconstructs the call.
This relies on the lambda compiling to a specific bytecode shape, the Java SAM lambda shape: an invokedynamic bound to LambdaMetafactory, producing a tiny synthetic class that implements the functional interface.
The Groovy problem
Groovy closures look syntactically similar:
BackgroundJob.enqueue { myService.processOrder(orderId) }
// or
BackgroundJob.enqueue(() -> myService.processOrder(orderId))
But Groovy compiles both forms to groovy.lang.Closure subclasses, not to a SAM-implementing synthetic. Even when you use the arrow syntax that Groovy 3+ supports, the result is still a Closure, not an invokedynamic-produced SAM. (Yes, there is some SAM-coercion at call sites for certain Java APIs, but it produces a different bytecode shape than javac does for the same source.)
JobRunr's ASM walker hits this, fails to recognise the shape, and throws IllegalArgumentException: Please provide a lambda expression. At runtime, not compile time, which is what makes it confusing the first time.
There is no language-level fix
You cannot make Groovy produce a javac-style SAM lambda from a closure. @CompileStatic does not help here. The closure-to-SAM coercion that exists in some Groovy code paths happens at the boundary into Java code, after the bytecode JobRunr is trying to analyse.
The pragmatic fix
JobRunr ships a second API specifically for languages where its lambda walker does not work: the JobRequest + JobRequestHandler pair.
class OrderJobRequest implements JobRequest {
Long orderId
OrderJobRequest() {}
OrderJobRequest(Long orderId) { this.orderId = orderId }
@Override Class<OrderProcessingService> getJobRequestHandler() { OrderProcessingService }
}
class OrderProcessingService implements JobRequestHandler<OrderJobRequest> {
@Override
void run(OrderJobRequest request) {
// your code
}
}
jobRequestScheduler.enqueue(new OrderJobRequest(orderId))
The request is a small serialisable value object. The handler is a regular Spring bean (or a Grails service). JobRunr serialises the request, picks it up on any worker, dispatches it back to the handler. No bytecode walking required.
Takeaway
If you are integrating any Java library from Groovy that uses ASM or similar bytecode analysis on a lambda (Mockito's argThat, some assertion libraries, some lazy-evaluation tricks), keep this in mind.
If you want the full Grails-flavoured walkthrough (GORM DataSource wiring, @Recurring, progress bars, retries, all the production gotchas), the guide is here: https://www.jobrunr.io/en/guides/jvm-frameworks/grails/
Demo repo with everything wired up: https://github.com/jobrunr/example-grails