Wednesday, November 11, 2026

Dusting Off an Old Java Library for Java 25 with Claude Code

Years ago I wrote a small library called completablefuture-utils. It solves an annoyance that has been in Java since version 8: ExecutorService and ScheduledExecutorService hand you back a plain old Future, when what you really want is a CompletableFuture you can chain with thenApply, whenComplete and friends. The library wraps those services so every method returns a CompletableFuture, without spinning up extra threads to poll for completion.

The last time I touched it, the README linked to the Java 11 docs and the build ran on Gradle 4.10.2. I'm on Java 17 now and want to move to Java 25, so I opened the project in VS Code and asked Claude Code a blunt question:

This code was for older versions of Java. Is it still relevant with Java 17?

Step 1: Is this library even still needed?

I half expected "no, the JDK does all this now." The answer was more nuanced, and it came from actually reading the code rather than guessing:

  • Partly replaced by the JDK. CompletableFuture.supplyAsync(supplier, executor) covers the basic submit case, and Java 9 added delayedExecutor, orTimeout and completeOnTimeout.
  • Still useful for cancellation. Calling cancel(true) on a plain CompletableFuture still doesn't interrupt the running task, even in Java 25. My wrapper passes cancel through to the underlying Future, so it does.
  • Still useful for periodic work. There's no JDK equivalent of scheduleAtFixedRate or scheduleWithFixedDelay that returns a CompletableFuture.

Then it pointed out problems I hadn't asked about:

  • The build couldn't run on Java 17 at all. Gradle 4.10.2 is too old, testCompile was removed in Gradle 7, and the JaCoCo and Mockito versions predate Java 17.
  • The wrappers caught Exception instead of Throwable. If a task threw an Error, the CompletableFuture would never complete, and anyone waiting on it would hang forever.
  • My periodic methods completed the future after the first run and then swallowed every later exception. The task also kept running after failures, which is the opposite of how ScheduledExecutorService behaves.

That last one stung a little. There was even a unit test asserting the wrong behavior.

Step 2: Modernizing the build

Then I said "let's update this repo and fix the issues you found," and watched it work.

The first thing it did impressed me. Instead of picking dependency versions from memory, it queried services.gradle.org and Maven Central for the current releases: Gradle 9.9.1, JUnit 6.1.3, Mockito 5.24.0 and JaCoCo 0.8.16.

Next it had to get around a chicken-and-egg problem. The old Gradle 4.10.2 wrapper can't run on Java 25, and you need a working Gradle to upgrade the wrapper. Gradle 4.10.2 does run on Java 11, which I still had installed, so it used Java 11 to bump the wrapper, then let Gradle 9.9.1 on Java 25 regenerate the wrapper scripts. I wouldn't have thought of that right away.

The new build.gradle is short and modern:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

configurations {
    // Mockito must be attached as a Java agent on JDK 21+
    mockitoAgent
}

dependencies {
    testImplementation platform('org.junit:junit-bom:6.1.3')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    testImplementation 'org.mockito:mockito-junit-jupiter:5.24.0'
    mockitoAgent('org.mockito:mockito-core:5.24.0') {
        transitive = false
    }
}

The Mockito agent setup is easy to miss. Newer JDKs warn about libraries that attach agents dynamically, and Mockito now recommends loading itself with -javaagent. Claude Code wired that into the test task without being asked.

Step 3: Fixing the bugs

With the build green, it moved on to the actual bugs. Every wrapper now catches Throwable, completes the future exceptionally, and rethrows, so the underlying Future records the failure too. The periodic tasks got a small helper that matches the JDK's semantics:

private static Runnable periodic(Runnable command, CompletableFuture<?> f) {
    return () -> {
        try {
            command.run();
        } catch (final Throwable t) {
            f.completeExceptionally(t);
            throw t; // lets the executor stop scheduling further runs
        }
    };
}

A successful run leaves the future incomplete, as a periodic ScheduledFuture does. The first failure completes it exceptionally and stops the schedule.

It also caught a raw-type warning and tidied it up with Java 16 pattern matching:

if (other instanceof ScheduledCompletableFuture<?> x) {
    return scheduledFuture.compareTo(x.scheduledFuture);
}
return Long.compare(getDelay(NANOSECONDS), other.getDelay(NANOSECONDS));

On the testing side, it rewrote the test that had been asserting the buggy behavior. It filled in a scheduleWithFixedDelay test that I had apparently left as an empty stub, and added tests for Errors, periodic success and failure, and cancelling a periodic task. It also updated the Bitbucket Pipelines image from the long-deprecated openjdk:8 to eclipse-temurin:25-jdk and fixed a couple of typos in my README.

The final result: all 20 tests pass on Java 25, the main code compiles with no warnings under -Xlint:all, and the build shows no Gradle deprecation warnings.

What I still did myself

This wasn't a "press a button and walk away" experience, and I don't think it should be:

  • I made the judgment calls. Changing how the periodic methods behave is a breaking change for anyone relying on the old behavior. Claude Code flagged that clearly instead of slipping it in.
  • It was upfront about what it hadn't fixed. If you call shutdownNow() on the executor, queued tasks never run and their futures never complete. Fixing that needs a different design, and it said so rather than claiming everything was solved.
  • It asked before touching shared things. It committed to a new branch instead of master. When I asked to rename master to main, it renamed the branch locally right away but stopped to check with me before changing anything on Bitbucket. It also pointed out that changing the default branch is a setting I have to flip in Bitbucket's web UI myself.

Takeaways

Upgrading an old project usually means an evening of reading migration guides, chasing Gradle errors one at a time, and checking which version of what supports which JDK. Here, the tedious parts (version lookups, the wrapper bootstrap, the Mockito agent setup, the dependency syntax) were handled in one pass. What I appreciated most, though, was the review I didn't ask for. My first question was simply "is this still relevant?", and the answer surfaced two real bugs that had been sitting in the code for years.

If you have a dusty library of your own that's stuck on an old JDK, it's worth pointing Claude Code at it and asking the same question.