Scaling JDK 25 Migration with AI at Halodoc

Introduction

At Halodoc, our Java microservices sit directly in the path of care : appointment booking, teleconsultation, pharmacy fulfillment, insurance claims. Keeping that stack current on a supported, secure JDK isn't optional busywork; it's part of keeping healthcare access reliable for the people who depend on it. Upgrading a handful of services to a new JDK is a routine chore. Upgrading 140 of them, without slowing the platform down or introducing risk to those services, is a different problem entirely. When we set out to move our Java microservices from JDK 21 to JDK 25, we didn't just plan a version bump: we built an AI-powered skill to do the mechanical work for us, identically and safely, every single time.

A word on what "AI-powered" means here, because it's the part most worth taking away. A script can repeat a change 140 times, that's a loop. What a script cannot do is cope with 140 services that are each slightly different: a POM laid out unusually, a config key nested somewhere new, a branch left behind by an earlier attempt, a shared library whose API has moved underneath the application code. A script meets any of those and stops, and every stop is an engineer context-switching into a service they have never opened. That is where fleet migrations actually go to die, not in the edits, but in the exceptions.

So we didn't run a script 140 times. We ran an agent. It reads each repository before changing anything, works out what that particular service needs, and when it finds something that doesn't match the service before it, it works the problem through rather than handing it back. What arrives at the end isn't an error to investigate, it's a merge request with an account of what changed, what was skipped and why, and what still needs a human. The exceptions were resolved before we ever saw them. What we reviewed was the resolution.

The deterministic migrators are tools the agent uses, not the thing doing the work. They exist because a POM rewrite has exactly one correct answer, and a model asked to produce it 140 times gives you 140 nearly-correct ones. But the judgment is the agent's: what to run, against what, and what to do when the service doesn't look like the last one.

In this post, we walk through why JDK 25 is worth the move, how we automated the migration end to end with a reusable Claude Code skill, and what broke along the way, including a garbage collector that never actually changed and two monitoring agents that flatly refused to boot on the new bytecode. We'll cover:

  • The "Why" : what JDK 25 actually gives you over JDK 21
  • The "How" : the AI skill that migrated all 140 services without a single hand-edited file
  • The "What If" : the gotchas we hit along the way, and how we worked around them
  • The "Where" : which parts of the job we handed to the AI, and which we deliberately didn't

Why JDK 25, and why now

JDK 21 wasn't broken. Three things made 25 the right move and made it worth doing across the whole fleet at once rather than service by service.

It's the next Long-Term Support release. Staying current on an LTS is how we keep receiving timely security and stability patches without being forced into a rushed upgrade later. Skipping a cycle doesn't avoid the work, it just makes the eventual jump bigger and the JDK 17 → 21 migration had already shown us exactly what a big jump costs.

Compact object headers became a production feature. Introduced as experimental in JDK 24 and finalized in 25, they shrink every object header from 96–128 bits down to 64. That's a heap reduction available to every service with no application code change, you enable a flag and the JVM does the rest. Across a fleet our size, a per-object saving compounds quickly.

The ecosystem was moving with or without us. Bytecode-level tooling : instrumentation agents, proxy libraries, Kafka clients are tightly coupled to the JDK it was built against. Several libraries we depend on had already moved ahead. Staying on 21 meant maintaining a growing set of pins and workarounds; moving to 25 let us refresh the entire dependency surface in one coordinated pass instead of a dozen scattered ones.

And it sets up what comes next. Getting every service onto the same current baseline is what makes any fleet-wide change practical afterwards : including the virtual-threads adoption programme that followed this migration, which relies on shared tooling that shipped with the same upgrade wave.

Those are the gains. Here's the bill. Upgrading a single Java service to JDK 25 isn't a one-liner. Six files have to change together, and they all have to change correctly:

  • pom.xml : compiler target, dependency upgrades, byte-buddy, Kafka client pins, lombok annotation paths
  • Dockerfile : base image tag
  • Jenkinsfile : shared library pin, agent label, JDK version
  • run : JVM flags, POSIX compliance
  • Kafka config YAMLs : client compatibility settings, per environment
  • Observability config YAMLs : APM and monitoring wiring, per environment

Six files across 140 repositories is 840 coordinated edits. Get one wrong and a service fails to boot : in production, on a healthcare platform.

But volume was never what made this hard. Six files is a checklist, and a checklist can be automated. What you can't put on a checklist is that after years of independent development, no two of those 140 services were in the same state and the work that actually costs you is everything that doesn't fit the pattern. The JDK 17 → 21 migration hit exactly that wall and solved it the only way available at the time: service by service, engineer by engineer, for a quarter.

How we applied skill-as-migrator to Halodoc's Java fleet

We built a reusable Claude Code skill : jdk25-upgrade that ships deterministic Python migrators bundled alongside its prompt. Point it at any Halodoc Java service and it handles the whole path from service name to open merge request.

Making that safe came down to four questions. Getting any one of them wrong makes automation more dangerous than doing the work by hand.

Why this needed an agent, not a script

It's a fair question. If the file edits are deterministic Python, why not write a script and loop it over 140 repositories?

Because there was no such thing as the change. There were 140 different ones.

Why a script would have broken this migration

Not "been slower." Broken it.

No two services needed the same thing

By the time we ran this, the fleet had years of drift in it. Services sat on different versions of our internal halodoc-* libraries, so the delta was different every time. A minority declared one of our internal halodoc-* Kafka libraries and needed client pins and a timeout adjustment; the majority needed neither and would have been broken by them. delivery.timeout.ms wasn't a constant to overwrite, it had a different existing value per service, and the correct new value was derived from whatever was already there. Some services had several poolStatConfig blocks, a few of which already carried an APM config stanza and had to be left untouched Some had no run script at all. Some still had branches lying around from an earlier attempt that had been reverted.

A script encountering any of that either corrupts the service or worse, it succeeds, and quietly leaves a service in a state that looks fine and isn't. Both outcomes cost an engineer.

The AI treated all 140 services as 140 separate problems, and resolved every one of them uniquely.

It examined each service before changing anything and worked out what that particular service needed, rather than applying a fixed recipe. Where a change didn't apply, it didn't apply it. Where a setting already existed, it built on what was there instead of overwriting it. Same rules everywhere, a different result for every service.

And when something went wrong which it did, differently, on different services, it resolved it rather than handing it back. One service was missing a piece of build configuration nothing else had needed. Another had application code broken by a library upgrade, entirely outside the files being migrated, the kind of failure no rule anticipates, because it isn't a configuration problem at all. In each case the AI diagnosed it in that service's own context and fixed it there, with an answer no other service received.

Neither reached an engineer as a failure. Both arrived as finished work that explained itself: what was changed, what was left alone, and what a human still needed to confirm before merge.

A script scales repetition and collapses on variance. Engineers handle variance and cannot scale to 140. The AI did both : carrying the repetitive work at machine speed while resolving every exception uniquely, without an engineer ever opening the service.

1. What code are we changing?

Some changes are universal, some aren't and guessing wrong is how you break a service. The skill inspects each service's POM and branches accordingly. Every service gets the compiler, image, pipeline, and runtime changes. Only services that actually depend on one of our internal halodoc-* Kafka libraries get the Kafka client pins and the timeout adjustment.

Concretely, per file:

pom.xml : sets java.version=25; standardizes our internal halodoc-* libraries, mockito, and other internal SDK versions to shared properties; adds byte-buddy and byte-buddy-agent; normalizes maven-compiler-plugin to use <release>${java.version}</release>; adds lombok to annotationProcessorPaths. Kafka services additionally pin kafka-clients 4.2.0 and parallel-consumer-core 0.5.3.3 (both public), plus an internal halodoc-* Kafka library to its compatible version.

pom.xml before/after: maven-compiler-plugin configuration

Dockerfile : bumps the base image tag to the JDK 25 build.

Jenkinsfile : pins the shared library, sets the new agent label and jdk_version.

run : fixes POSIX compliance (source → .) and adds -XX:+UseCompactObjectHeaders, picking up JDK 25's finalized compact headers, which drop per-object headers from 96–128 bits to 64.

Kafka config YAMLs : bumps delivery.timeout.ms by +100, a compatibility requirement of the 4.2.0 client.

Kafka config before/after: delivery.timeout.ms bumped for kafka-clients 4.2.0

Observability config YAMLs — monitoring wiring updated in the same pass, so no manual follow-up is left dangling per service.

2. Is everything we're changing under version control?

This one sounds trivial and isn't. The migration only works because all six file types live in Git application repos and per-environment config alike. Anything configured by hand outside version control is invisible to the automation, and would have silently stayed on JDK 21.

3. How do we actually make the changes?

Every run starts as a dry run. Before a branch is created, the skill prints exactly what it intends to change, line by line for each service in a unique way. Nothing is applied until that preview is accepted.

The diffs shown above aren't mockups. That's real output from a real merged MR.

Notes

Two monitoring agents refused to boot. Our legacy APM agent's load-time-weaving instrumentation can't parse JDK 25 bytecode (class file major version 69) and crashes the JVM at class-load time, before the application starts. The fix: the migrator strips the java-agent line entirely and repoints observability to our current APM platform, injecting its config block into each service's stage and prod config so metrics keep flowing through a different pipeline.

Kafka jumped a major version. kafka-clients 3.x → 4.2.0 is not a drop-in swap — consumer and producer behaviour, rebalancing, and serializer compatibility all need reviewer sign-off, not just a clean compile. The fix: pin kafka-clients 4.2.0, parallel-consumer-core 0.5.3.3, and our internal halodoc-* Kafka library together, to versions known to be mutually compatible, then bump delivery.timeout.ms by +100 as the client requires : all scripted, and verified per service in the dry-run diff before merge.

Byte-buddy and Lombok needed version bumps to keep annotation processing and proxying working under the new bytecode target.

The garbage collector that never changed. An early theory was that JDK 25 had switched the default GC, and that this explained our memory behaviour. It hadn't. G1 remains the JVM default all the way through JDK 25; the only related change, JEP 523, makes G1 the default even in small or constrained environments and that targets JDK 27, not this release. Ruling it out early stopped us tuning a lever that didn't exist.

Results:

Every service, every file, automated

All 140 services now build and deploy on JDK 25, and not one of them required a hand-edited pom.xmlDockerfileJenkinsfilerun script, or Kafka/observability config YAML. That's all six file types the migrators touch : 840 edits, none of them by hand.

What changed between the two migrations

We'd done this before. The JDK 17 → 21 upgrade covered the same fleet, the same kinds of files, and the same class of compatibility breakage and it was run the only way available at the time: by hand, service by service, engineer by engineer.

JDK 17 → 21JDK 21 → 25
How each service was changedBy handOne skill, run per service
Time per serviceHours15–20 minutes
Who opened each repositoryAn engineer, every timeOnly a reviewer
When something didn't fitAn engineer stopped and investigatedResolved before it reached review
Consistency across the fleet140 chances to divergeSame rules applied everywhere
Where the knowledge livedIn people, and in a runbookIn a skill anyone can re-run

What actually got better wasn't speed, it was where the effort went.

On the earlier migration, most of the cost was mechanical: reading a POM, matching versions, checking whether a service used Kafka, fixing a pipeline, then waiting on review. Every one of those steps was work an engineer had already done on the previous service, and would do again on the next. Multiply by a fleet and the expensive resource isn't compute, it's attention and attention was being spent on edits.

This time the mechanical work disappeared into the skill, and the exceptions, the genuinely different problems, the ones that used to stop someone mid-flow were worked through and arrived already resolved, with an explanation attached. Engineers spent their time confirming decisions rather than reconstructing problems.

The second thing that got better is that it doesn't decay. The JDK 17 → 21 knowledge lived in the people who did it and in a document that went stale. This time it lives in a skill that anyone can run again which is why the next fleet-wide upgrade starts from a very different place.

The edits stopped being the expensive part. Judgement did.

A note on resource footprint

Of the services flagged for review after migrating, five needed CPU or memory limit increases around their migration date.

CPU/memory limit and request changes for the 5 flagged services, per k8s config history

JVM heap sizing was not broadly increased in this window. The single heap change was tied to a pod restart rather than the migration and it was a decrease followed by a partial recovery, not a net increase.

Conclusion

Migrating from JDK 21 to JDK 25 isn't about chasing a version number, it's a supported, production-ready set of memory and language improvements, and a few years of runway before the next LTS decision.

But the JDK was never the hard part. The hard part was that 140 services meant 140 different starting points. What actually costs you in a fleet migration isn't repeating a change, it's everything that doesn't fit the pattern, and that's the part a script can't carry and an engineer can't repeat 140 times.

What the skill changed was who handles that. Every service was examined on its own terms, every exception resolved in its own context, and each one came back as finished work with an explanation attached, leaving a human to confirm a decision rather than reconstruct a problem. That's what turned a fleet-wide upgrade from something we survived into something we could finish.

What's next for skill-as-migrator

The JDK version was never the interesting part. The durable outcome is the pattern: a fleet-wide change written once, executed identically 140 times, reviewed one service at a time.

Beyond JDK. The same skill-as-migrator approach applies directly to Spring Boot major bumps, dependency standardization sweeps, and config-format migrations. The next fleet-wide change shouldn't cost 840 hand edits either.

Join Us

Scalability, reliability and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels, and if solving complex problems with challenging requirements is your forte, please reach out to us with your resumé at careers.india@halodoc.com.

About Halodoc

Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke.

Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which allows users to access the benefits of cashless outpatient services in a more seamless way; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services, including Haloskin, a trusted dermatology care platform guided by experienced dermatologists.