When Generative AI Meets a "Big Ball of Mud"
Every organization has one: a repository from the mid-2000s, built with Ant on Java 1.5, untouched since the Bush administration. When I inherited such a project, the obvious move was to hand the codebase to an LLM and ask, "How do I run this?" The results were instructive — and not in the way I expected.
The experience became a case study in how generative AI fails and succeeds in legacy modernization. The critical distinction wasn't the quality of the AI; it was the framing. Asking for a happy path produced plausible but structurally false answers. Asking for a forensic audit produced a roadmap for safe recovery.
The Tourist Prompt: Confident and Wrong
The first interaction was, in retrospect, naively framed. I opened a chat with a standard LLM and asked for a high-level summary and a simple "Hello World" example. The AI delivered exactly that: a sparkling build.gradle, a clean HelloBlobStore.java, and instructions for database connectivity. On the surface, it looked like a miracle.
The generated build file revealed the problem's depth. The AI hallucinated dependencies, suggesting commons-pool2 (v2.x) where the legacy code relied on org.apache.commons.pool (v1.x). These libraries have incompatible APIs; running the generated build would have crashed with "Class Not Found" errors, sending me down a rabbit hole of debugging modern code that was never meant to be modern.
It also made structural assumptions. The AI confidently referenced a standard Maven layout under src/main/java, while the actual project lived in a non-standard Ant structure under java/com/legacycorp/.... It described the codebase it expected, not the one that existed.
Finally, the AI's pristine "Hello World" masked the underlying rot. The showcased PooledBlobStoreImpl conveniently ignored that the core implementation SimpleBlobStoreImpl was not thread-safe, that error handling routinely swallowed exceptions, and that the suite labeled "Unit Tests" were actually integration tests requiring a live MySQL instance.
Blindly refactoring after this advice — introducing generics or changing List to ArrayList<> — would have broken hidden behaviors in a fragile system. The lesson: AI defaults to optimism. When asked "How do I run this?", it assumes you can. In a restoration project, optimism is fatal.
Running an Archaeological Dig
The second approach took a different stance. Instead of asking for help running the code, I asked why the build failed at all. The prompt shifted the AI's role from tour guide to construction inspector, explicitly forbidding it from summarizing the README and requesting a forensic code audit instead.
The response was immediate and sobering: a risk assessment with a verdict of "critical rewrite recommended." More importantly, it supplied evidence for three key findings.
Carbon Dating the Codebase
The AI examined syntax like geological strata. The presence of a build.xml and the absence of a pom.xml placed the project in the pre-2010 "Ant Era." Legacy use of org.apache.commons.pool.ObjectPool (Version 1.x) alongside raw types like Map rather than Map<String, String> dated it to Java 1.5 of the 2005–2008 transition period, predating generics, try-with-resources, and standard directory layouts.
The Transliteration Trap
The most significant insight: the code was Perl logic forced into Java syntax. The procedural mindset manifested in SimpleBlobStoreImpl, a god class handling low-level socket connections, protocol parsing, and business logic. The codebase was aggressively "stringly-typed," passing raw Map<String, String> objects and manually constructing protocol strings instead of using domain objects like Device or File. A single typo in a string key such as get("fiel_id") meant a runtime crash, not a compile-time error.
The Lying Tests
The audit exposed a dangerous illusion in the test suite. Tests relied on LocalFileBlobStoreImpl, a complete re-implementation that wrote to local disk instead of traversing the network. They proved only that this mock worked in isolation. The networking code, thread-unsafe pooling, and fragile protocol parser — the most volatile components — were entirely bypassed.
Containment Before Repair
Following the Tourist path would have meant blind changes with passing tests masking broken production code. The forensic report changed the strategy: touch nothing.
The decision was containment, not repair. No bug fixes, no dependency updates, no whitespace reformatting. The legacy code was an absolute liability — too fragile to touch and too opaque to trust. The goal was to wrap it in an isolated, standardized Docker environment, establishing its current state before attempting any analysis or change. With evidence grounded, roles clear, and a step-by-step strategy in place, the AI's usefulness finally outweighed its enthusiasm.
The Containment Phase: Running Code As-Is
Once the audit was complete, the goal shifted from understanding the code to running it unmodified. If the original test suite could pass in a correctly reconstructed environment, that would provide a verifiable baseline for the artifact. To guide this effort, the AI's persona switched from Architect to Senior DevOps Engineer.
Prime Directives for a 2008 Codebase
The core mission was brownfield restoration: establish a standardized
environment that faithfully reproduced the software's era. Three prime
directives kept the process honest. First, no updates to legacy build tools or
the Java version—the artifact was strictly from 2008. Second, containment over
modernization: the original Ant build.xml stayed untouched, and all work
happened inside an isolated Docker container. Third, zero code changes: no
quick visibility fixes like slapping public on classes to work around build
issues. If it worked in 2008, it had to work now in the right container.
The temptation to modernize crept in regardless. The first instinct was a
classic tourist impulse: “I can't change the Java, but surely I can swap Ant
for Gradle 8.” Acting on that urge produced a spectacular failure. Dropping raw
Java 1.5 source files into a modern Gradle 8 container collapsed immediately.
The problem wasn't the legacy code—it was that the foundational rules of the
environment had shifted over two decades. Code routinely accessed
package-private classes across package boundaries, like TestBackend reaching
into Backend. In 2008, Ant and Eclipse were permissive about such structural
violations. By 2026, Gradle 8 and modern JDKs had become strict enforcers of
encapsulation.
/src/test/java/com/legacycorp/blobstore/test/TestBackend.java:12:
error: Backend is not public in com.legacycorp.blobstore; cannot be accessed from outside package
Backend backend = new Backend(trackers, true);
^
The AI's predictable suggestion was to make the classes public. But that
broke the zero-code-changes directive. Modifying production source just to
appease a build tool is the start of a slippery slope.
The Time Capsule Strategy
The pivot involved a “time capsule” approach: build a containment zone
mirroring 2008 standards. Docker was the obvious venue, and the search began
for an old image combining Java 6 and Ant 1.5. This immediately met a hardware
reality check. Available Java 6 images were built for x86 (linux/amd64),
while the host was an Apple Silicon (ARM64) laptop. Emulation layers like
Rosetta or QEMU work in theory, but introduce unpredictable variables into an
already fragile build process. If the build fails, is it a code defect or the
emulation layer choking on twenty-year-old binaries?
Eliminating that variable meant changing environments: moving from the ARM laptop to a native Intel machine with a modern i9 processor. Software archaeology sometimes requires choosing the right shovel. Progress only came after stopping the fight against host architecture and working on native ground.
The “Wet” Test: Making Reality Match the Code
With the compiler working on Intel, a stubborn integration test named
TestBlobStore.java remained. This “wet” test was littered with hardcoded
assumptions tied to the original developer's local setup. It tried to connect
to qbert.legacycorp.com:7001 and referenced a magic file path at
~/Projects/blobstore/…. A modern refactor would simply delete those lines, but
containment mode forbade touching the test file. Instead of changing the code
to fit current reality, reality had to change to fit the code.
Docker Compose provided the infrastructure illusion. Acting as a network
engineer, the AI helped set up two tricks. Network trickery: spin up a modern
BlobStore container and use a Docker network alias so the test runner believes
this container is the long-lost qbert.legacycorp.com. Filesystem trickery:
mount the current source directory inside the container at the exact path the
original engineer used in 2005.
The outcome appeared in the docker-compose.yml configuration.
services:
blobstore:
image: hrchu/blobstore-all-in-one:latest
networks:
default:
aliases:
- qbert.legacycorp.com
builder:
image: blobstore-legacy-builder
volumes:
- .:~/Projects/blobstore/java/com/legacycorp/blobstore/
command: ant test
This orchestrated illusion brought complete stabilization. Running
docker-compose up fired up the legacy test suite flawlessly. The test looked
up qbert.legacycorp.com and routed directly to the local Docker container;
the hardcoded path resolved through the live volume mount.
The build succeeded. Without altering a single byte of historical source code, a twenty-year-old application had full functionality restored. The code was verifiable—and ready to be considered for migration into the future.
Phase III: The Lift (Unwrapping the Artifact)
With the artifact safely stabilized inside its Docker, Java 6, and Ant “Time Capsule,” I had a verifiable baseline. The code was provably functional in its native environment, so any failure from this point onward would be the direct result of our active modernization, not pre-existing decay. With that safety net in place, I launched the project fifteen years forward, aiming for Java 8 and Gradle.
Why Java 8 Was the Only Entry Point
The choice of Java 8 was pragmatic, not aesthetic. I needed the project to run natively on Apple Silicon (ARM64), but both ends of the timeline were blocked. Modern JDKs (Java 17+) no longer support compiling legacy Java 1.5 source code, rejecting the old -source 1.5 flag entirely. Meanwhile, ancient JDKs like Java 6 refuse to run natively on ARM64, trapping you in buggy emulation layers.
Java 8 is the single version that satisfies both constraints: it is the last version to support compiling Java 1.5 targets and one of the earliest installable natively on modern Mac hardware. It became our architectural entry point by necessity.
The “Java 17 Trap” and the Gradle Pivot
My first instinct was to use the latest Gradle 8.5 release, but that collided with reality: Gradle 8 requires Java 17 just to run its internal daemon, and Java 17 cannot compile legacy Java 1.5 source. To break the deadlock, I pivoted to Gradle 7.6, the last modern-ish Gradle version that still runs on a Java 8 JVM. That created a clean compatibility chain:
Apple Silicon → Java 8 JVM → Gradle 7.6 → Java 1.5 Source
Mapping the Legacy Structure
Rather than merely wrapping the old build.xml, I configured Gradle to map directly onto the legacy layout. Since the Ant script was obscuring the underlying logic, I overrode the modern defaults and pointed Gradle at srcDirs = ['java'] instead of expecting the standard src/main/java structure.
The legacy tests were another obstacle. Because they were built as old-school main() methods rather than a modern JUnit suite, the standard gradle test command couldn't discover them. I wired up a custom JavaExec task named runLegacyTest to invoke those entry points manually:
java {
sourceCompatibility = JavaVersion.VERSION_1_5
targetCompatibility = JavaVersion.VERSION_1_5
}
sourceSets {
main {
java {
srcDirs = ['java']
}
}
}
tasks.register('runLegacyTest', JavaExec) {
mainClass.set(project.findProperty('mainClass'))
classpath = sourceSets.main.runtimeClasspath
}
Discovery: The “Lying Tests”
With the build modernized, runLegacyTest ran successfully—suspiciously fast. An audit of TestBlobStore.java exposed a classic legacy anti-pattern: the silent swallow. The code caught failures and smothered them before they could propagate to the runtime:
public static void main(String[] args) {
try {
BlobStore bs = new PooledBlobStoreImpl(...);
bs.storeFile("test_file", ...);
System.out.println("Success!");
} catch (Exception e) {
System.out.println("Failed: " + e.getMessage());
e.printStackTrace();
}
}
A human reading the console output would spot this as a blatant failure, but an automated build tool sees it differently. Because the exception is caught and handled internally without rethrowing or exiting, the process finishes with exit code 0. The backend connection could fail completely, yet our modern pipeline would still report a green pass.
Hardening the Baseline
To eliminate that false security, I hardened the test harness by instructing the AI to refactor it so exceptions would propagate all the way up the stack. It was my first structural change to the legacy codebase, and its purpose was singular: force the baseline to be honest. Instead of an error-smothering try-catch, I removed the catch block entirely so any problem would crash the application naturally:
public static void main(String[] args) throws Exception {
BlobStore bs = new PooledBlobStoreImpl(...);
bs.storeFile("test_file", ...);
}
The build immediately turned red. That red was a win—it meant I was finally seeing the system's true state. Over the next hour I traced and repaired the broken connection configurations until the pipeline flipped back to green. That time, it was an honest green.
The AI-Compiler Feedback Loop
With the tests truthful and the build green, the compiler was still screaming with warnings:
Note: Some input files use or override a deprecated API. Note: Recompile with -Xlint:deprecation for details. Note: Some input files use unchecked or unsafe operations. Note: Recompile with -Xlint:unchecked for details.
To address the accumulated technical debt systematically, I abandoned general-purpose refactoring prompts and instead used the compiler itself as the driver. I explicitly enabled the -Xlint:unchecked flag in the Gradle configuration to surface the exact lines producing violations. Whenever a build captured a specific warning—such as an unsafe call to a raw type—I fed those error logs to the AI with a targeted prompt to refactor only those lines using modern Java generics.
This localized strategy neutralized real runtime risks. The legacy codebase relied on Java 1.5-era raw collections, where the compiler had no idea what objects lived inside, forcing blind casts vulnerable to sudden ClassCastExceptions:
BEFORE: The “Raw Type” Risk (Java 1.4 Style)
public class Backend {
private List hosts;
private Map deadHosts;
public void reload(List trackers, boolean connectNow) {
this.hosts = trackers;
this.deadHosts = new HashMap();
}
InetSocketAddress host = (InetSocketAddress) hosts.get(index);
}
Passing this snippet and its warning log to the AI produced a swift conversion to proper Java 8 type-safe standards. The burden of validation shifted from runtime guesswork to compile-time enforcement:
AFTER: The “Type Safe” Standard (Java 8 Style)
public class Backend {
private List<InetSocketAddress> hosts;
private Map<InetSocketAddress, Long> deadHosts;
public void reload(List<InetSocketAddress> trackers, boolean connectNow) {
this.hosts = trackers;
this.deadHosts = new HashMap<>();
}
InetSocketAddress host = hosts.get(index);
}
Disciplined repetition of this cycle across every file eventually produced a successful build with zero warnings. The historic artifact wasn't just operational; it was standardized.
Phase IV: The Refactor
Although the artifact was unwrapped, it remained disorganized—convoluted java/com/... folder structures, test suites built as standalone main() scripts, and production code littered with raw types. With a modern build chain humming and a hardened safety net in place, I transitioned from containment to full architectural renovation.
Fixing the Workbench First
Before touching production code, I sanitized the project skeleton. Source files moved out of the java/ root into the industry-standard src/main/java, which let me delete the custom Gradle directory workarounds entirely—conforming to conventions made the build tool work out of the box.
Next came a comprehensive JUnit 5 migration to convert the primitive legacy main() scripts (TestBackend, TestBlobStore) into genuine unit tests. Along the way, I replaced crude System.out.println("Error") traps with Assertions.assertEquals() calls. The payoff was granular, automated test reporting—standard, unambiguous green checkmarks instead of manual log audits.
The Testcontainers Trap
I considered moving from the manual docker-compose setup to TestContainers for fully self-contained tests. The attempt collapsed quickly. The migration degenerated into a “Big Bang” refactor: overhauling the test runner, network topology, and startup logic simultaneously while wrestling with Docker-in-Docker networking issues on ARM.
The lesson was clear: momentum is oxygen. As soon as I realized I was fighting the tooling more than recovering the code, I aborted the experiment and accepted the “External Sidecar” pattern—running docker-compose up manually. It was reliable, and it worked. Pragmatism won over over-engineered perfection.
Putting the Pool to the Test
The core modernization was complete, but two loose ends remained before the restoration could be called finished. The first was LocalFileBlobStoreImpl.java, a legacy mock that still needed to implement the new generic-based BlobStore interface. The second was StoreALot.java, a multi-threaded load-testing tool buried in the repository.
These files were the key to verifying concurrency rules. Any misalignment in the pooling logic inside PooledBlobStoreImpl would cause StoreALot to fail immediately with a ConcurrentModificationException or, worse, succumb to silent race conditions. To prove the modernizations were thread-safe, these files needed a full overhaul followed by an aggressive stress test.
The task list was precise:
- Refactor
StoreALot.java—keep it executable as a main script, but clean up syntax with generics and modern loggers. - Point the test runner at the backend using
PooledBlobStoreImplagainst the Docker container aliasqbert.legacycorp.com:7001. - Swap manual threads for a modern
ExecutorServiceto handle parallel load without throwing concurrency exceptions.
The results provided the empirical proof needed. Firing 100 iterations across 10 concurrent threads directly at the Docker-contained BlobStore backend confirmed the thread-safety architecture. The system relied on PooledBlobStoreImpl, using Apache Commons Pool, to provision isolated backend instances to each active thread. Under intense simulated load, the deep modernizations—generics, JUnit migration, and structural collection swaps—had not destabilized the core historical logic.
Twenty-year-old code that was completely uncompilable, untestable, and broken had become a modern, thread-safe, fully containerized Java 8 library.
The Handover
A restoration mission is complete only when it meets a clear definition of done. For this project, that meant bringing the system to a state where the code was completely runnable, testable, and predictable on modern hardware. That bar transformed the repository from an opaque archaeological mystery into standard technical debt.
Environment Cleanup
To spare the next developer from repeating the dig, the final pass purged every historical artifact that belonged firmly to the past. The legacy Ant script build.xml was removed. The old lib/ folder—a loose bag of unversioned, hardcoded JARs—was emptied. The abandoned IDE artifacts .classpath and .project were swept away. Running rm build.xml severed the fragile link to the Ant era and forced the repository to rely on the modern Gradle engine.
The Project Roadmap
The repository came with a map. A comprehensive README.md was generated to reflect the new standardized reality. It specifies basic prerequisites like Docker and Java 8+, and offers a quick start that builds the project with a single ./gradlew build. Testing the infrastructure requires docker-compose up -d followed by ./gradlew test. What was once an intimidating mystery box is now a predictable, standard Java library; the next engineer faces routine onboarding rather than forensic investigation.
The Transformation: Before vs. After
| Feature | Day 0 (The Archive) | Day N (The Product) |
|---|---|---|
| Build System | Ant | Gradle 8 |
| Compiler | Java 1.5 | Java 8 |
| Environment | “Works on my machine” | Docker |
| Testing | Manual Scripts | JUnit 5 |
| Safety | Runtime Risk | Compile-time Safety |
| Confidence | Swallowed Exceptions | Hardened Tests |
| Onboarding | “Good luck figuring it out” | README.md |
Final Thought: The Augmented Archaeologist
The most important lesson was about human agency. A helpless "tourist prompt"—vaguely asking the machine to "fix this for me"—collapsed because the AI lacked a foundational understanding of the environment and the rigid constraints of the past. Success arrived only when shifting mindsets to direct the execution: first as an archaeologist identifying architectural decay, then as a DevOps engineer designing the containerized time capsule, and finally as an architect defining a strict refactoring policy.
The AI did not restore the system on its own. It was wielded as a force multiplier: handling tedious translation layers like the Ant-to-Gradle conversion, drafting the Dockerfiles, and systematically squashing fifty distinct compiler warnings. With the human focused on high-level strategy, the codebase became entirely runnable, testable, and predictable—fully equipped to endure the next ten years.



