Fixing a Horrific Gradle Build with Java Custom Tasks

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.

programmer coding at night - Free Nighttime Coding Session Image - Technology, Programmer ...
programmer coding at night – Free Nighttime Coding Session Image – Technology, Programmer …

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.

programmer coding at night - Free Nighttime coding session Image - Coding, Programmer, Night ...
programmer coding at night – Free Nighttime coding session Image – Coding, Programmer, Night …

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

Questions readers ask

Why does Gradle 9.4.0 break legacy build.gradle configuration cache?

Gradle 9.x enforces strict configuration caching rules that clash with inline Groovy closures commonly found in older build.gradle files. These closures often read environment variables, scan directory trees, and parse text files during the configuration phase. After bumping to Gradle 9.4.0, the configuration cache refused to cooperate with this legacy logic, causing ‘Configuring projects…’ to hang for about four minutes on a clean build.

How do I move Gradle build logic from Groovy to Java in buildSrc?

Because Gradle runs on the JVM, you can write pure Java classes in the buildSrc directory instead of inline Groovy. The article shows extending DefaultTask, annotating it with @CacheableTask, and exposing abstract ConfigurableFileCollection and RegularFileProperty getters for inputs and outputs. A @TaskAction method contains the logic. You then call tasks.register in build.gradle, passing the fully qualified Java class name.

What performance improvement did rewriting a Groovy task as a Java Gradle task give?

Rewriting the inline Groovy task that analyzed dependency manifests as a compiled Java ManifestCleanerTask cut clean build time from 4 minutes 12 seconds down to just 18 seconds. The configuration cache finally hit because the logic was isolated in a proper compiled class. The author ran this on an M3 Max MacBook Pro, whose fans had been spinning up under the original Groovy implementation.

Why use @CacheableTask and @PathSensitive annotations on a custom Gradle task?

These annotations let Gradle’s configuration cache actually do its job by declaring inputs and outputs explicitly. The article’s ManifestCleanerTask marks the class @CacheableTask, annotates input files with @InputFiles and @PathSensitive(PathSensitivity.RELATIVE), and the output with @OutputFile. Combined with strict typing from Java, you get real compiler errors instead of weird runtime closure failures, plus proper isolation so caching works reliably.