The Archaeologist’s Copilot

Restoration of a 20-year-old Java “Big Ball of Mud” using AI and Docker

This article explains the approach I used to modernize a Java 1.5 codebase that no longer built reliably on modern machines. My early use of LLMs gave me plausible answers that did not hold up in the codebase. Progress came when I grounded the process in evidence, using AI to support analysis, validation in a stable Docker environment, and gradual refactoring protected by tests. The main takeaway is practical: AI was most useful when constrained by evidence, clear roles, and a step-by-step modernization strategy.

16 July 2026



The “Tourist” Trap

We all have “that” repository in our organization. The one written in 2005, built with Ant, using Java 1.5. It hasn't been compiled since the Obama administration, and it certainly doesn't run on your new Apple Silicon MacBook.

When I inherited this “Brownfield” project, the temptation is to treat Generative AI as a universal translator. I paste the code into an LLM and ask the most natural question in the world: “How do I run this?”

This is what I call the “Tourist Prompt.” Like a tourist visiting ancient ruins, I am asking for a guided tour and a gift shop souvenir. I want a happy path.

In our experiment, I tried exactly this. I opened a chat with a standard LLM and asked:

“Hi, I need to start working with this library. Can you please act as a Senior Developer and help me get started? Read the repo and give me a high-level summary... and a simple 'Hello World' code example.”

The Hallucination of Competence

The AI acted like a polite, eager-to-please tour guide. It scanned the README.txt, ignored the decades of dust, and confidently generated a modern “Starter Kit.” It gave me a pristine build.gradle file and a clean HelloBlobStore.java. It told me exactly how to connect to the database. On the surface, it looked like a miracle.

The AI-generated build.gradle

plugins {
   id 'java'
}

group = 'com.legacycorp.blobstore'
version = '1.1'

sourceCompatibility = '1.8'
targetCompatibility = '1.8'

repositories {
   mavenCentral()
}

dependencies {
   implementation 'org.apache.commons:commons-pool2:2.11.1'
   implementation 'log4j:log4j:1.2.17'
   testImplementation 'junit:junit:4.13.2'
}

test {
   useJUnit()
}

The result was a lie — and a structural lie at that. By generating a modern build file, the AI merely painted a fresh coat of paint over a crumbling structural wall.

First, it hallucinated dependencies by suggesting commons-pool2 (v2.x) when the legacy code actually relied on org.apache.commons.pool (v1.x). Because these libraries have completely different APIs, blindly running the AI's code would have crashed the build with “Class Not Found” errors, sending me down a frustrating rabbit hole of debugging “modern” code that was never meant to be modern.

Next came the structural gaslighting. The AI confidently assumed a standard Maven layout src/main/java, completely ignoring the reality of a non-standard Ant structure java/com/legacycorp.... It was describing the reality it wanted to see, not the one that actually existed.

Finally, it hid the underlying rot. Its pristine “Hello World” example featured PooledBlobStoreImpl, omitting the fact that the core implementation SimpleBlobStoreImpl wasn't even thread-safe, the error-handling code routinely swallowed exceptions, and the so-called “Unit Tests” were actually integration tests that required a live MySQL database to run.

The Lesson

AI defaults to optimism. When I ask “How do I run this?”, it assumes I can run it. In a restoration mission, optimism is fatal.

If I had followed the Tourist path, I would have started refactoring immediately—changing List to ArrayList<> or adding Generics—likely breaking hidden behaviors I didn't fully understand. I would have been making blind changes to a fragile system without establishing its current state.

To truly restore a brownfield project, I need to stop acting like Tourists and start acting like Archaeologists.

Phase I: The Analysis

After the “Tourist” prompt failed by offering a modern build that couldn't exist, I realized that what was needed here was not a tour guide, but a construction inspector. I shifted my mental model from “How do I run this?” to “Why did this fail?”. I reset the context with the AI, asking it to be critical rather than helpful.

The Archaeologist Prompt

I crafted a prompt designed to strip away the optimism. I assigned the AI a specific persona: Senior Legacy Systems Architect. I explicitly forbade it from summarizing the README (which is often a lie in legacy projects) and ordered a “Forensic Code Audit”.

I am conducting a technical due diligence assessment on this legacy Java
repository: https://github.com/nikmalykhin/java-blobstore.

Act as a Senior Legacy Systems Architect. Your goal is not to tell me what the
code "does," but to evaluate its structural health and "age."

Do not summarize the README. Instead, perform a "Forensic Code Audit" focusing
on these four pillars:

1.  **Carbon Dating (The Era):**
    * Based on syntax (e.g., raw types vs. generics, annotations), imports, and
    build tools (Ant vs. Maven), estimate the specific Java version (e.g., 1.4,
    1.5, 6) and the year this code was likely written.
    * Cite specific lines of code as "forensic evidence."

2.  **Architectural Integrity (The Structure):**
    * Does it follow standard separation of concerns (Transport vs. Protocol vs.
    Logic), or is it a "Big Ball of Mud"?
    * Identify any "God Classes" that are doing too much.

3.  **Data Flow & Typing (The "Stringly" Trap):**
    * Analyze how data is passed. Is it using proper Domain Objects, or is it
    relying on "Stringly-typed" Maps and raw arrays?
    * Look for "Leaky Abstractions" where protocol details leak into business logic.

4.  **The "Safety" Check (Error Handling & Threading):**
    * Look for anti-patterns in error handling (swallowed exceptions, returning null).
    * Analyze the threading model. Is `SimpleBlobStoreImpl` thread-safe?

Output your findings as a structured "Risk Assessment Report" for a stakeholder
deciding whether to refactor or rewrite.

The AI dropped the polite facade immediately. Instead of a “Starter Kit,” it handed me a Risk Assessment Report with a brutal verdict: “critical rewrite recommended“.

Finding 1: Carbon Dating the Artifact

The AI analyzed the syntax like an archaeologist uncovering historical strata, citing evidence rather than guessing. Looking first at the historical record, it found a build.xml file without any sign of a pom.xml, placing the project squarely in the pre-2010 “Ant Era.” Next, it flagged key syntax markers, spotting the legacy use of org.apache.commons.pool.ObjectPool (Version 1.x) alongside raw types like Map instead of Map<String, String>. This led to an unmistakable verdict: this was Java 1.5 code written during the transition era of 2005–2008, completely predating modern generics, try-with-resources, and standard directory layouts.

Finding 2: The “Transliteration” Trap

The most damning insight was that this was actually Perl code masquerading as Java; the original author had simply taken a procedural Perl script and forced it into Java syntax. This procedural mindset manifested directly in SimpleBlobStoreImpl, a massive monolithic god class that tried to handle everything from low-level socket connections and protocol parsing to core business logic. Furthermore, the codebase was aggressively “stringly-typed”. Instead of utilizing proper domain objects like Device or File, the code constantly passed around raw Map<String, String> objects and manually constructed raw protocol strings just to open a file. This introduced an immense operational risk: a single typo in a string key, such as get(“fiel_id”), would trigger a catastrophic runtime crash instead of being caught safely at compile time.

Finding 3: Lying Tests

The Archaeologist revealed that the perceived test coverage was nothing more than a dangerous illusion. The entire test suite relied heavily on LocalFileBlobStoreImpl, a complete re-implementation of the storage system that wrote directly to the local disk instead of traversing the network. While these tests successfully proved that this local mock worked flawlessly in isolation, that superficial success masked an alarming reality: the actual networking code, the thread-unsafe pooling, and the fragile protocol parser—the absolute most volatile parts of the system—were being completely bypassed.

The Decision: Containment over Repair

The report saved me from disaster. Had I followed the optimistic “Tourist” advice to refactor SimpleBlobStoreImpl immediately, I would have blindly introduced generics and broken the fragile parsing logic. The tests would have still passed—thanks to that deceptive local mock —but the actual production code would have been completely non-functional.

Realizing the code was an absolute liability, too fragile to touch and too opaque to trust. I made the strategic decision to halt all active changes. I refused to fix the bugs, update the dependencies, or even reformat the whitespace. Instead, I transitioned directly into a phase of complete containment, wrapping the legacy code inside an isolated, standardized Docker environment before trying to analyze it any further.

Phase II: The Wrap

With the audit complete and the “Critical Legacy” label applied, my goal shifted. I didn't want to change the code; I just wanted to run it. If I could get the existing tests to pass, I would have a verifiable baseline. To achieve this, I switched AI persona from Architect to Senior DevOps Engineer.

The Mission: Brownfield Restoration

For this phase of brownfield restoration, the core mission was to establish a standardized environment. To guide the AI and actively prevent insidious “modernization creep”, I established a strict set of prime directives. First, we had to preserve the era, meaning absolutely no updates to the legacy build tools or the Java version—we were strictly mimicking the year 2008. Second, I prioritized containment over modernization, keeping the original Ant build.xml file and running the entire process inside an isolated Docker container to avoid polluting my host machine. Finally, there were to be absolutely no code changes; I refused to slap public modifiers onto classes just to fix visibility bugs. If it worked in 2008, it had to work now inside the right container.

Yet, despite these strict rules, my first instinct was still secretly tainted by the tourist mindset—a classic case of modernizer's hubris. I caught myself thinking, “Okay, I can't change the Java code, but surely I can swap out this ancient Ant build for Gradle 8, right?” Acting on that impulse, I asked the AI to perform a swift “lift and shift,” grabbing the raw Java 1.5 source files and dropping them directly into a modern Gradle 8 container.

The result was a spectacular crash. The build failed not because the legacy code itself was buggy, but because the foundational rules of the software environment had radically shifted over two decades. The legacy code routinely relied on accessing package-private classes from entirely separate packages (such as TestBackend reaching into Backend). Back in 2008, Ant and Eclipse were incredibly permissive about these structural violations; by 2026, Gradle 8 and modern JDKs had become strict, unyielding enforcers of encapsulation.

The Build Failure Log

/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);
        ^

Faced with this roadblock, the AI's immediate suggestion was predictable: “Just add public to the class.” But I refused. Doing so would directly violate one of my prime directive of making zero code changes. Modifying production source code solely to appease a modern build tool is a slippery slope, and I wasn't going to step onto it.

The Pivot: The “Time Capsule” strategy

Realizing that I couldn't stabilize the artifact in a modern environment, I pivoted to a “Time Capsule” strategy. If I wanted to capture this system, I had to build a containment zone that strictly mirrored the standards of 2008. I turned to Docker to recreate the exact environment the code was born in, searching for an old image that bundled Java 6 and Ant 1.5 together.

But I hit an immediate hardware reality check. The only available Java 6 Docker images were compiled for x86 (linux/amd64), while I was attempting to run the build on a modern Apple Silicon (ARM64) laptop. While emulation layers like Rosetta or QEMU are theoretically possible, they introduce a dangerous, unpredictable variable into an already fragile process. If the build fails, how do you know whether it's an inherent code defect or just the emulation layer choking on twenty-year-old binaries?

To eliminate that variable entirely, I changed my environment. I abandoned the laptop and switched to a native Intel machine powered by a modern i9 processor. The lesson here was clear: sometimes software archaeology requires the right shovel. I only made progress when I stopped fighting the host architecture and moved directly onto the native ground of the artifact.

The “Wet” Test: Bending Reality

Once I had the compiler working on Intel—completing the “dry” capsule—I faced the final structural challenge: a stubborn integration test named TestBlobStore.java. This “wet” test was a pure artifact of its time, littered with hardcoded assumptions tied directly to the original developer's local machine. Specifically, it looked for a magic host, trying to connect to qbert.legacycorp.com:7001, and relied on a magic file path located at ~/Projects/blobstore/…. In a standard modern refactor, I would have simply deleted these lines. But because I was strictly in containment mode, touching the test file was off the table. Instead of changing the code to fit modern reality, I had to change reality to fit the code.

The solution lay in environment emulation via Docker Compose. I prompted the AI to act as a network engineer to help me pull off some infrastructure illusions. First, we executed some network trickery: I spun up a modern BlobStore container and used a Docker network alias to trick the test runner into believing this container was actually the long-lost qbert.legacycorp.com. Next came the filesystem trickery, where I configured Docker volumes to mount our live, local source code directory inside the container at the exact, identical path engineer had used back in 2005.

This environment trick materialized in my docker-compose.yml file:

The network 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

Configures the specific network alias and maps the local directory to the expected legacy volume path.

This orchestrated illusion brought about complete stabilization. When I executed docker-compose up, the legacy test suite fired up and ran flawlessly. It looked up qbert.legacycorp.com and seamlessly routed straight to my local Docker container; it reached out for engineer's old hardcoded path and found our live volume mount instead.

The build succeeded. Without changing a single byte of historical source code, I had successfully restored full functionality to a twenty-year-old application. The environment was stable, the code was finally verifiable, and I could at long last think about moving it into the future.

Phase III: The Lift (Unwrapping the Artifact)

With the artifact safely stabilized inside the “Time Capsule” of Docker, Java 6, and Ant, I finally possessed a verifiable baseline. I now had concrete proof that the code was fully functional in its native environment, meaning any failures from this point forward would be the direct result of our active modernization efforts, not pre-existing rot. With this safety net firmly established, I began the transition, launching the project fifteen years into the future with the ultimate goal of reaching Java 8 and Gradle.

The Hardware Rationale

The choice of Java 8 was not aesthetic; it was a pragmatic necessity driven by my hardware constraints. I needed to run the project natively on Apple Silicon (ARM64), but that goal crashed into a double-ended technical wall. On one side of the timeline, modern JDKs (Java 17+) have dropped support for compiling legacy Java 1.5 source code entirely, rejecting the old -source 1.5 flag. On the other side, ancient JDKs like Java 6 refuse to run natively on ARM64 architecture, trapping you in buggy emulation layers.

So I turned to Java 8, the single, specific version capable of satisfying both ends of the timeline. Because it stands as the absolute last version to support the compilation of Java 1.5 targets and one of the earliest versions that can be installed natively on modern Mac hardware, it became our perfect architectural entry point.

The “Java 17 Trap”

I hit the first hard technical wall when choosing the tool version. My instinct was to use the latest release, Gradle 8.5, but this choice immediately crashed into a wall: Gradle 8 requires Java 17 just to run its internal daemon, and as we already noted, Java 17 is incapable of compiling legacy Java 1.5 source code.

To resolve this bottleneck, I settled on a pivot to Gradle 7.6. This stands as the absolute last modern-ish Gradle version that can still execute on a Java 8 JVM, allowing me to establish a perfect chain of environmental compatibility:

Apple Silicon -> Java 8 JVM -> Gradle 7.6 -> Java 1.5 Source

The Execution: Mapping the Legacy Structure

I didn't just wrap the old build.xml. Realizing the Ant script was actively obscuring the underlying logic, I configured Gradle to map directly to the legacy directory structure. To achieve native compilation, I overrode the modern defaults and explicitly instructed Gradle to look for the source code in srcDirs = ['java'] instead of expecting the standard src/main/java layout.

Next, I had to tackle the legacy test runner. Because the historical tests were structured as old-school main() methods rather than a modern JUnit suite, the standard out-of-the-box gradle test command couldn't find them. To bypass this limitation, I wired up a custom JavaExec task named runLegacyTest to execute those test entry points manually.

Mapping Gradle to the Legacy Layout

  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
  }

Configures Gradle for Java 1.5 compatibility, maps the source directories to the legacy layout, and registers a execution task for legacy tests.

The “Lying Tests” Discovery

With the build modernized to Gradle, the runLegacyTest task executed successfully. But the tests ran suspiciously fast. When I audited the source of TestBlobStore.java to find out why, I discovered a classic legacy anti-pattern: the silent swallow. The code was actively capturing failures and smothering them before they could bubble up to the runtime environment:

Legacy Code Pattern

  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();
      }
  }

The catch block logs the exception but swallows it, so the error isn't noticed.

While a human reading the console outputs would easily recognize this as a blatant failure, an automated build tool sees it very differently. Because the exception is caught and handled internally without throwing it further or exiting the program, the process finishes with a perfect exit code 0. These tests were completely misleading; the backend connection could fail entirely, yet our modern pipeline would still confidently report a green pass.

Hardening the Baseline

To strip away this false security, I initiated a process of deliberate hardening. I instructed the AI to refactor the old test harness so that it would explicitly throw exceptions all the way up the execution stack. This marked my very first structural change to the legacy codebase, and it was done with a singular purpose: to force my verifiable baseline to become completely honest. Instead of wrapping the operations in an error-smothering blanket, I stripped out the try-catch block entirely and forced the application to crash naturally if something went wrong:

Hardened Pattern

  public static void main(String[] args) throws Exception { 
      BlobStore bs = new PooledBlobStoreImpl(...);
      bs.storeFile("test_file", ...); 
  }

There's no catch block, so any exception crashes the application.

Suddenly, the build turned bright red. Far from a defeat, this was a massive narrative victory—a red build meant I was finally looking at the unvarnished reality of the system. I spent the next hour tracing down and repairing the broken connection configurations until the build pipeline finally flipped back to green. But this time, it was an honest green.

The AI-Compiler Feedback Loop

Once the tests were “honest” and the build turned Green, I was faced with a mountain of technical debt. The build was successful, but the compiler was screaming:

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 systematically clean this up, I moved away from general refactoring and established a tight, iterative AI-compiler feedback loop. I didn't just ask the AI to vaguely “fix the codebase”; instead, I used the compiler itself as the ultimate driver.

First, I explicitly enabled the -Xlint:unchecked flag inside the Gradle build configuration to force the compiler to reveal the exact source lines triggering the violations. Whenever the build ran and captured a specific warning block—such as an unsafe call to a raw type list—I fed those exact error logs directly to the AI with a highly targeted prompt, instructing it to refactor only those specific lines to resolve the warnings using modern Java generics.

This highly localized strategy was incredibly effective at neutralizing historical runtime risks. For example, the original codebase used primitive, Java 1.5-style raw collections where the compiler had no idea what objects actually lived inside them, forcing me to rely on blind, dangerous type casting:

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); 
  }

The compiler has no idea what is inside hosts and deadHosts. Developers must cast objects blindly, if hosts contains a String instead of an address, the highlighted line will cause a ClassCastException at runtime.

By passing this exact snippet and its accompanying warning log to the AI, it swiftly updated the architecture to the proper Java 8 type-safe standard, transferring the burden of validation 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);
  }

The compiler guarantees that hosts only contains InetSocketAddresses. No casting required. Zero runtime risk.

By maintaining this disciplined, repetitive cycle across every file, I eventually crossed the finish line with a successful build and absolutely zero warnings. The historic artifact wasn't just functional; it was officially standardized.

Phase IV: The Refactor

Although I had successfully unwrapped the historical artifact, it remained fundamentally disorganized. The core codebase was still locked in a convoluted, non-standard java/com/... folder structure, and its test suite still consisted of primitive, standalone main() scripts. To make matters worse, the source code itself was completely riddled with raw types—a legacy artifact of the Java 1.5 era that forced developers to cast objects blindly and constantly exposed the system to unpredictable runtime crashes. Yet, with a modern build chain now humming and a hardened safety net finally secured beneath me, I was at long last equipped to transition from strict containment into a full-scale architectural renovation.

Mastering the Craft

Before attacking the production code, I had to fix the workbench. I started with a much-needed phase of sanitization, moving the source files out of their archaic java/ root folder and into the industry-standard src/main/java layout. Making this shift allowed me to delete my previous custom Gradle directory workarounds entirely; by finally bowing to standard conventions, the build tool simply worked out of the box.

With the project's skeleton straightened out, I tackled a comprehensive JUnit 5 migration to convert the primitive legacy main() scripts—such as TestBackend and TestBlobStore—into genuine unit tests. Throughout the implementation, I systematically swapped out the old, crude System.out.println(“Error”) traps for proper Assertions.assertEquals() statements. This immediately paid off with a deeply satisfying result: I gained granular, automated test reporting, permanently freeing me from having to manually audit endless text logs just to check if a test had passed in favor of receiving a standard, unambiguous green checkmark.

The TestContainers Trap

I got ambitious and considered replacing the manual docker-compose setup with TestContainers to make the tests truly self-contained, but the attempt quickly collapsed. The migration rapidly degenerated into a messy “Big Bang” refactor—I found myself trying to overhaul the test runner, the network topology, and the startup logic all at once, while simultaneously wrestling with complex Docker-in-Docker networking issues on ARM architecture.

This friction taught me a vital engineering lesson: momentum is oxygen. The moment I realized I was spending all my energy fighting the tooling rather than recovering the actual code, I made the conscious decision to abort the experiment. I gladly accepted the “External Sidecar” pattern—running docker-compose up manually—because it was reliable and it worked, deliberately choosing ground-level pragmatism over over-engineered perfection.

The Final Sweep: Concurrency & Stress Testing

I had successfully unwrapped the artifact and hardened its core, but two final loose ends remained before I could confidently declare the project's restoration complete. First, there was a forgotten sibling: LocalFileBlobStoreImpl.java. This legacy mock implementation desperately needed to be updated to implement our brand-new, generic-based BlobStore interface. Second, I had to address the ultimate proof of our architecture: StoreALot.java, a multi-threaded load-testing tool buried deep within the historical repository.

These files mattered immensely because they held the keys to verifying our concurrency rules. If the pooling logic inside PooledBlobStoreImpl was even slightly misaligned, StoreALot would immediately crash with a ConcurrentModificationException or succumb to silent race conditions. To prove that my modernizations were actually thread-safe, I needed to overhaul these files and push them to their absolute limits.

To execute this final performance engineering phase, I prompted my AI copilot to act as a senior performance engineer. Together, we systematically modernized the old load-testing script, cleaning up its raw syntax with generics and modern loggers while ensuring it remained executable. I instructed the AI to configure the test runner to target our backend using PooledBlobStoreImpl to hit the Docker container alias at qbert.legacycorp.com:7001. Finally, we swapped out the primitive, manual threads for a modern ExecutorService to guarantee the system could elegantly handle a parallel load without buckling under concurrent exceptions.

We had modernized the core API. Now we must verify thread safety.

  1. Modernize the Load Test: Refactor StoreALot.java. It’s currently a main script; keep it executable but clean up the syntax with Generics and modern Loggers.
  2. Target the Backend: Ensure it uses PooledBlobStoreImpl to hit the Docker container alias (qbert.legacycorp.com:7001).
  3. Concurrency Verification: Run with ExecutorService instead of manual threads. Handle parallel load without throwing ConcurrentModificationException.

This rigorous orchestration yielded the definitive empirical proof I needed. I launched the stress test, firing 100 iterations across 10 concurrent threads directly at my Docker-contained BlobStore backend. The results were crystal clear: the application's entire thread-safety architecture successfully relied on PooledBlobStoreImpl—utilizing Apache Commons Pool—to seamlessly provision isolated backend instances to each active thread. By verifying this behavior under intense, simulated real-world conditions, I confirmed that our deep modernizations—the generics, the JUnit migration, and the structural collection swaps—had not destabilized the core historical logic.

I had finally done it. I took a twenty-year-old piece of code archaeology that was completely uncompilable, untestable, and broken, and transformed it into a modern, thread-safe, and fully containerized Java 8 library.

Conclusion: The Handover

A software restoration mission is never truly finished just because an artifact suddenly becomes functional; it is only complete when it meets a clear, unyielding definition of what it means to be done. For this legacy project, that milestone wasn't about achieving theoretical perfection, but rather about bringing the system to a specific, verifiable state where the code was completely runnable, testable, and predictable on modern hardware. By clearing that precise bar, I successfully transformed the repository from an opaque archaeological mystery into something much more familiar and manageable: standard technical debt.

Scrubbing the Environment

To ensure the next developer doesn't have to repeat my tedious archaeological dig, I switched my AI persona one last time to act as a lead repository maintainer. With this final objective in mind, I identified and systematically purged every historical artifact that belonged firmly to the past. First went build.xml, the legacy Ant script that had dictated the repository's rules for decades. Next, I emptied out the old lib/ folder—permanently discarding a loose bag of unversioned, hardcoded JARs—and swept away .classpath and .project, which were nothing more than abandoned artifacts from long-forgotten IDE setups. Running rm build.xml stood as the final, cathartic act of modernization; it officially severed our fragile link to the ancient Ant era and permanently forced the repository to rely on my modern Gradle engine.

The Project Roadmap: README.md

I didn't just leave behind a clean repository; I left a map. Working with the AI, I generated a comprehensive README.md file that perfectly reflects this new, standardized reality. Instead of an undocumented labyrinth, the file outlines a completely frictionless path to productivity, specifying basic prerequisites like Docker and Java 8+, and offering a dead-simple quick start that builds the project with a single ./gradlew build. Testing the entire infrastructure is now just as straightforward, requiring a quick docker-compose up -d to spin up the backend dependencies followed by a standard ./gradlew test. This single document completely transforms the project from an intimidating mystery box into a predictable, standard Java library, permanently shifting the experience for the next engineer from a grueling forensic investigation to a routine, standard onboarding.

The Transformation: Before vs. After

FeatureDay 0 (The Archive)Day N (The Product)
Build SystemAntGradle 8
CompilerJava 1.5Java 8
Environment“Works on my machine”Docker
TestingManual ScriptsJUnit 5
SafetyRuntime RiskCompile-time Safety
ConfidenceSwallowed ExceptionsHardened Tests
Onboarding“Good luck figuring it out”README.md

Final Thought: The Augmented Archaeologist

The most important lesson from this experiment was fundamentally about human agency. When I initially leaned on the helpless “Tourist Prompt”—vaguely asking the machine to “fix this for me”—the entire attempt collapsed because the AI lacked a foundational understanding of the environment and the rigid constraints of the past. Success only arrived when I fluidly shifted mindsets to direct the execution: acting first as an archaeologist to identify the true architectural decay, then as a DevOps engineer to design the containerized time capsule, and finally as an architect to define a strict refactoring policy.

The AI didn't magically restore this historical system on its own; rather, I restored it by wielding the technology as a powerful force multiplier. It took care of the tedious, repetitive translation layers—churning through the conversion from Ant to Gradle, drafting the Dockerfiles, and systematically squashing fifty distinct compiler warnings—while I focused entirely on high-level strategy. Because of this active partnership, the codebase is no longer an intimidating, tangled black box. It has become entirely runnable, testable, and predictable—fully equipped to endure the next ten years and perfectly positioned for whatever future refactoring awaits it.


Acknowledgments

Thanks to Matteo Vaccari for the series of articles on AI-assisted modernization. They were a key inspiration for the idea of ​​using AI-powered refactoring for truly old software, such as this Java 1.5 code.

Many thanks to Martin Fowler for his feedback and guidance throughout the writing process. His help made this article clearer and, hopefully, more readable.

I used AI in this article. I started by using Gemini to highlight the key moments from my experiment and turn my notes into an outline. Then I used it to help draft sections from that outline, which I reviewed, commented on, and revised by hand. Finally I used AI for a pass on flow and grammar. The experiments, conclusions, and final wording were all reviewed and edited by me and GitHub Copilot.

Significant Revisions

16 July 2026: published