Architecture · Tooling
The Image Nothing Noticed
Couldn't Build
A Spring Boot 4 migration that started by checking whether it was even necessary — a stale doc said yes, git history said it had already happened two weeks earlier — ended a day later with every check green and the actual deployable container image unable to build at all, because nothing in CI was watching the one file whose meaning had just changed underneath it.
The root doc still described the Java implementation as running Spring Boot 3.3. Before starting that migration, checking git log -S on the build file instead of trusting the doc turned up something the doc hadn't caught up to: Java had already made the jump, two weeks earlier, in a single commit whose message read less like a version bump and more like a small essay of everything it had to route around. The doc was stale, not the code. That one check saved a day of redoing work that had already shipped — and set the pattern for the rest of the round: check the actual state before trusting what anything claims about it, docs included.
Kotlin's Turn, and a Dependency Chain That Broke Quietly
Kotlin hadn't migrated yet, and Boot 4's Gradle plugin turned out to no longer integrate with io.spring.dependency-management at all — the switch to native platform() BOMs was straightforward, but the first build afterward looked clean for the wrong reason: build -x test skips compiling the test sources entirely, so a real dependency-resolution break in the test classpath sat there unnoticed until something actually tried to compile against it.
Testcontainers had its own version of the same shape of surprise. The project already imported a testcontainers-bom, and it turned out to have been a complete no-op the entire time — Boot 3's own BOM had been silently supplying a version for the old-named artifacts, so the explicit import never did anything at all. Only renaming to Testcontainers 2.x's actual artifact names — testcontainers-junit-jupiter and its siblings — made the previously-invisible BOM start mattering.
A Library That "Doesn't Exist," According to the Wrong Source
Resilience4j's Boot-3 starter crashes at application startup under Boot 4 — a verifier built into the library itself refuses to run. Searching for a Boot-4 replacement came up empty on the package search index's own web UI, which read as "nothing published yet." A rate limiter is small enough to hand-wire, so that's what happened: a manually assembled registry reading the same configuration keys the missing starter would have. It worked. Tests passed. The harness passed.
Only later, cross-checking against what Java's own dependency list already used, did the actual answer turn up: resilience4j-spring-boot4 existed on the real package repository the whole time — a direct probe of the repository's own file path returned it immediately. The web search UI simply hadn't indexed it. The hand-wired workaround came out, replaced by the real starter Java had already been using for two weeks. A package index's search box not finding something is a claim about that index, not about what's actually published — worth a direct probe before trusting it.
One Line of Actual Application Code
Underneath all the dependency and configuration churn, exactly one line of real business logic needed to change. Spring Security 7's JSpecify nullability annotations mark PasswordEncoder.encode as nullable, and Kotlin's null-safety caught it immediately at compile time — a checkNotNull() wrap around a call that, in practice, never actually returns null. Everything else in the entire migration was infrastructure and configuration; this was the only place the framework upgrade touched logic a developer had actually written.
The Check Nobody Had Pointed at the Right File
The migration looked finished: build green, full test suite green, harness green, pushed. The next day, an unrelated scheduled job — a weekly container-image security scan — failed. The Kotlin service's Dockerfile was still building on a gradle:8.10 base image, not the repository's own 8.14+ wrapper, and Spring Boot 4.1's Gradle plugin requires Gradle 8.14 or later. The actual deployable container image had been unable to build for a full day, with every other check reporting green, because the image-scan workflow's trigger only watched changes to the Dockerfile itself — and this migration had touched the build file, never the Dockerfile.
The base image version was the easy part to fix. The trigger was the part worth fixing properly: it now also watches each language's dependency manifest, not just its Dockerfile, so the next toolchain bump that quietly outgrows a base image gets caught by the same push that caused it, instead of by whatever scheduled job happens to run next.
Three separate points in the same migration, each one trusting a source that turned out to be wrong about the thing that actually mattered: a doc claiming work was still pending that had already shipped, a search index claiming a package didn't exist that was sitting right there in the repository, and a green CI board claiming a migration was complete while the one artifact it was ostensibly protecting couldn't be built at all.
build.gradle.kts — the native platform() BOMs, resilience4j-spring-boot4, and everything else the migration touched · docker-image-scan.yml — the widened trigger