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.

Tuesday, September 5, 2023

Webpack Tree Shaking Experiments

Overview

Webpack is capable of tree shaking unused code in order to make an application's bundles sizes smaller and more optimized. However, it can only do so under the correct circumstances.

I am performing a series of experiments iterating on a simple JavaScript application. I will modify its configuration and code to determine which settings result in the minimal bundle sizes.

Setup

The source code used for all of the experiments can be found here.

Here is a list of the main software used:

  • NodeJS 18.17.1 & NPM 9.6.7 (included with Ubuntu Lunar Lobster)
  • NPM workspaces to separate out each experiment
  • BundleAnalyzerPlugin to visualize artifact results
  • HtmlWebpackPlugin to generate the index.html file used to import the bundled JavaScript application.


Initial Application

This application was created by using the Webpack getting started guide but adjusting it slightly as it is placed in the ./packages/initial-app/ directory as an NPM workspace.

There are three main modifications made to the getting started app to test various parts of code splitting and tree shaking:

  1. Using multiple functions from the lodash library (join, sortBy, cloneDeep)
  2. Importing a json file. This is common when using i18n and for statically accessed configuration data.
  3. Importing another js file within the app. The goal here is to attempt to have this be a portion of the application that can be chunked out and ideally lazy loaded.

Results

 After running `npm run build`, we get the following files in the dist dir:
-rw-rw-r-- 1 brian brian      107K Sep  5 19:37 app.js
-rw-rw-r-- 1 brian brian      336   Sep  5 16:34 app.js.LICENSE.txt
-rw-rw-r-- 1 brian brian      236   Sep  5 16:34 index.html

We really are only concerned with app.js and will ignore the others going forward.

The following diagram is the output from the BundleAnalyzerPlugin:


The lodash parsed size in the bundle is 68.48 KB.

Looking a the bundle analyzer output, we can see we receive a single js file with lodash.js, users.json, timer.js, and index.js all nested inside. This gives us our baseline that we can now make improvements on.