Skip to content

Feedback from a 152k-line Java 8 to 25 migration: the silent behavioural changes that cost the most, and which have no recipe #1227

Description

@jonico

Context

I ran a controlled A/B experiment migrating a 152,000-line application (Spring 3.0.5 to 6.2,
Hibernate 3.5.5 to 6.6, Java 8 to 25, JUnit 4/3 to 5) twice: once using OpenRewrite heavily, once
with OpenRewrite forbidden and everything done by hand. Both arms succeeded and both landed at the
same place.

The useful finding for you is where the time actually went, because it was not where I
expected. Recipes handled the mechanical work well - the two arms converged to within 1% on
namespace rewriting (396 vs 392 jakarta imports in .java, 201 vs 202 in AspectJ ITDs). The
dominant cost in both arms was diagnosing silent behavioural change: things that compile
fine, produce no warning, and fail at runtime.

Below are the four that cost the most. Each is verified to have no recipe and no detection in
rewrite-migrate-java 3.42.1, rewrite-spring 6.37.1, rewrite-hibernate 2.25.0 or
rewrite-testing-frameworks 3.44.0, by grepping every META-INF/rewrite/*.yml. I am filing them
together because they are one theme; happy to split them, and happy to contribute any of them.

I have opened the Spring 6 trailing-slash case separately as

1. Surefire 3 silently ignores forkMode — 158 failing tests from one cause

forkMode was removed in maven-surefire-plugin 3.0.0-M8. It is not an error and produces no
warning; the parameter is ignored. A suite relying on forkMode=always for isolation then leaks
static state between test classes.

This was the single largest failure cluster of the entire migration - 158 tests - and it
presented as a mass application regression rather than as a build-configuration change.

Closing - this recipe request for Surefire plugin migration would belong in
rewrite-static-analysis or rewrite-migrate-java rather than the core rewrite repo.
Consider filing there if still desired.

So: filing it here as invited. The mapping is documented and mechanical -
forkMode=always to reuseForks=false with forkCount=1, forkMode=once to
reuseForks=true, forkMode=never to forkCount=0. It matters much more now than in 2023,
because the JUnit 5 recipes bump surefire to 3.x, so the migration itself introduces the
breakage.

2. GenerationType.AUTO changes meaning between Hibernate 3/4 and 5+

Under Hibernate 3 with a MySQL or HSQLDB dialect, AUTO meant a native identity column. From
Hibernate 5 it prefers a sequence or table generator, which defers the INSERT past
persist()
. Code that reads the generated identifier straight after persist() breaks, and
the failures point at the application rather than at the ORM.

No recipe changes it (GenerationType appears only inside an unrelated example). Both arms
independently hand-edited the same 19 entities to GenerationType.IDENTITY.

Belongs in rewrite-hibernate, as part of the MigrateToHibernate5x/6x chain: rewrite AUTO to
IDENTITY when a MySQL/HSQLDB-family dialect is configured, or - more conservatively - mark every
@GeneratedValue(strategy = AUTO) for review.

3. @ModelAttribute parameters became data-bound from URI template variables

Since Spring 3.1, URI template variables are bound onto @ModelAttribute arguments. On an
entity-backed @ModelAttribute this silently overwrites the identifier from the path, and
Hibernate then rejects the update ("identifier of an instance was altered"). The fix is
@ModelAttribute(binding = false) or an @InitBinder exclusion.

86 parameters needed it here. No recipe addresses it. Belongs in rewrite-spring.

4. No AspectJ support, and no warning that files are being skipped

OpenRewrite has no .aj parser, so AspectJ inter-type declarations are invisible to every
recipe. I could not find an existing issue for this - ".aj" parser and inter-type declaration
both return zero results across the org.

Concretely: 197 .aj files, of which 47 contained 201 javax.* import lines that every
jakarta recipe silently left untouched, and 72 were test ITDs that declare @RunWith,
@ContextConfiguration and the @Test methods themselves. The consequence was decisive - the
OpenRewrite arm abandoned the JUnit 5 objective entirely because the declarations it needed to
change were unreachable, while the hand-editing arm completed it across 86 files.

A full AspectJ parser is obviously a large ask and I am not asking for it. But detecting .aj
files in the source set and reporting "N files are outside the reach of every recipe"
is small,
and would convert a silent and misleading success into a known gap. As it stands rewrite:run
reports success on a project where a substantial share of the relevant declarations were never
examined.

Why I think this is worth your time

Where recipes applied, they were clearly better than non-deterministic LLM inference work: faster, more consistent, and they
produced a reviewable diff. The reason the recipe-using experiment did not win overall is that the
mechanical share of a fifteen-year framework migration is smaller than it looks, and none of the
four items above are covered. Items 1 and 2 in particular are narrow, mechanical, and would have
removed the two largest failure clusters I hit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions