So there I was, staring at my terminal at 1:30 AM. The screen just said > Configuring projects... and sat there. For four minutes. I could hear the fans on my M3 Max MacBook Pro actually spinning up, which almost never happens unless I’m doing something terribly wrong.
Well, that’s not entirely accurate — we had just bumped our main repository to Gradle 9.4.0. I figured it would be a quick update. And I was wrong. The configuration cache completely vomited on our legacy build scripts.
If you maintain an older codebase, you probably have a bunch of inline Groovy closures sitting in your build.gradle files. Ours were reading environment variables, scanning directory trees, and parsing text files — all during the configuration phase. Gradle 9.x enforces strict configuration caching rules, and that’s where the trouble started.
The “Gotcha” Nobody Mentions
I realized something obvious that I usually forget: Gradle runs on the JVM. You don’t have to write build logic in scripts at all. You can just write pure Java in the buildSrc directory.
Moving complex logic into compiled Java classes gives you strict typing, actual compiler errors instead of weird runtime closure failures, and the ability to use modern Java 21 features. Plus, it isolates the logic so the configuration cache can actually do its job.
Writing the Custom Task
I ripped out the inline Groovy task that was analyzing our dependency manifests and rewrote it as a proper Gradle task in Java. Here’s what that looked like:
package com.company.build;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@CacheableTask
public abstract class ManifestCleanerTask extends DefaultTask {
// Interfaces are great here for injecting different rules for different environments
public interface DependencyFilter {
boolean isValid(String dependencyLine);
}
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
public abstract ConfigurableFileCollection getInputFiles();
@OutputFile
public abstract RegularFileProperty getOutputFile();
@TaskAction
public void generateCleanManifest() {
Path outputPath = getOutputFile().get().getAsFile().toPath();
// Simple lambda implementing our interface
DependencyFilter filter = line -> !line.trim().isEmpty()
&& !line.startsWith("#")
&& !line.contains("SNAPSHOT");
try {
List<String> cleanLines = getInputFiles().getFiles().stream()
.flatMap(file -> {
try {
return Files.lines(file.toPath());
} catch (Exception e) {
throw new RuntimeException("Failed reading " + file.getName(), e);
}
})
.filter(filter::isValid)
.map(String::toUpperCase)
.distinct()
.collect(Collectors.toList());
Files.write(outputPath, cleanLines);
getLogger().lifecycle("Wrote {} clean dependencies to manifest.", cleanLines.size());
} catch (Exception e) {
throw new TaskExecutionException(this, e);
}
}
}
To use it, I just added three lines to my actual build.gradle:
tasks.register('cleanManifests', com.company.build.ManifestCleanerTask) {
inputFiles.from(fileTree('src/main/resources/manifests'))
outputFile = layout.buildDirectory.file('clean-manifest.txt')
}
The old inline Groovy mess took 4m 12s on a clean build. The new compiled Java task? 18 seconds. The configuration cache finally hit.
Stop putting complex logic in your build scripts. Put it in buildSrc where it belongs. Use Java. Use types. Let
