Skip to content

Upgrade to Java 21 + Spring Boot 3.5 (javax→jakarta, Security 6, DGS/MyBatis majors)#175

Open
devin-ai-integration[bot] wants to merge 20 commits into
mainfrom
migration/java-21-migration
Open

Upgrade to Java 21 + Spring Boot 3.5 (javax→jakarta, Security 6, DGS/MyBatis majors)#175
devin-ai-integration[bot] wants to merge 20 commits into
mainfrom
migration/java-21-migration

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 17, 2026

Copy link
Copy Markdown

Summary

Upgrades the app from Java 11 + Spring Boot 2.6.3 to Java 21 + Spring Boot 3.5.3, including the framework-major "big bang" (javaxjakarta, Spring Security 6, graphql-java/DGS/MyBatis majors) and a modernized build/test toolchain. Built up on the integration branch in dependency order — Phase 0 (independent dep pre-work) → Phase 1 (build config) → Phase 2 (framework big bang) → Phase 3 (test/E2E finish) — each phase merged and gated.

Verification (Java 21): ./gradlew clean test spotlessCheck → BUILD SUCCESSFUL, 68 tests, 0 failures, spotless clean. CI runs clean test -x jacocoTestCoverageVerification. The jacocoTestCoverageVerification gate (~0.31 instruction ratio) is pre-existing on main and already excluded from CI; it was left untouched (not weakened). The seleniumTest E2E smoke suite ran green on real Chrome 137.

Build / toolchain

  • Gradle wrapper 7.4 → 8.10.2; sourceCompatibility/targetCompatibility 11 → 21.
  • org.springframework.boot 2.6.3 → 3.5.3, io.spring.dependency-management 1.0.11 → 1.1.7.
  • CI gradle.yml JDK 11 → 21 (zulu).

Framework (javax → jakarta, Security 6)

  • javax.validation.*/javax.servlet.*jakarta.* across all sources (javax.crypto.* stays JDK). No explicit javax.validation jar on the classpath, so @Valid resolves to the jakarta provider — the HTTP 422 controller tests pass (dual-provider silent-skip pitfall avoided).
  • WebSecurityConfig: dropped WebSecurityConfigurerAdapter for a @Bean SecurityFilterChain lambda DSL:
http.csrf(disable).cors(...).sessionManagement(STATELESS)
    .exceptionHandling(authenticationEntryPoint = HttpStatusEntryPoint(UNAUTHORIZED))
    .authorizeHttpRequests(... requestMatchers(...) ...)   // was authorizeRequests()/antMatchers()
    .addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class)

same route rules; PasswordEncoder/CorsConfigurationSource beans kept.

Framework-coupled dependencies

  • DGS/GraphQL: codegen 5.0.67.0.3; pinned graphql-dgs-spring-boot-starter:4.9.21 → DGS platform BOM graphql-dgs-platform-dependencies:9.2.2 + unversioned (classic) starter. codegen 7 now generates its own PageInfo, so typeMapping = ["PageInfo": "graphql.relay.PageInfo"] keeps datafetcher code unchanged. graphql-java 22 changed DataFetcherExceptionHandler.onException(...)handleException(...) returning CompletableFutureGraphQLCustomizeExceptionHandler rewritten accordingly.
  • MyBatis starter + starter-test 2.2.23.0.4; Flyway BOM-managed (runs the SQLite migrations in tests with no extra module).
  • CustomizeExceptionHandler.handleMethodArgumentNotValid: Spring 6 param HttpStatusHttpStatusCode.
  • Phase 0 (already on the branch): joda-time → java.time.Instant; jjwt 0.11.20.12.6 (parser()/verifyWith()/parseSignedClaims, Keys.hmacShaKeyFor); sqlite-jdbc 3.46.0.0; spotless 6.25.0; jacoco 0.8.12.

Tests / E2E

  • REST-Assured 4.5.15.5.1 (all 4 artifacts); removed mockito-inline:4.0.0 (Mockito 5 from spring-boot-starter-test is inline by default); @MockBean@MockitoBean across the 9 API test classes.
  • Selenium BOM-managed 4.31.0 (dropped the misleading explicit 4.15.0 pin); webdrivermanager 5.6.25.9.2; testng 7.8.07.10.2; httpclient5 BOM-managed (Boot 3.5 HttpClientAutoConfiguration needs TlsSocketStrategy). seleniumTest smoke suite passes on Chrome 137.

Note

  • Spotless target now excludes build/** (Gradle 8 strict task validation flags a spotless input overlapping another task's output dir).

Link to Devin session: https://partner-workshops.devinenterprise.com/sessions/be5aba3063104b3194133398d225f3fd
Requested by: @mbatchelor81


Open in Devin Review

…ase-0c-sqlite-upgrade

Bump org.xerial:sqlite-jdbc from 3.36.0.3 to 3.46.0.0
…ase-0d-spotless-jacoco

Bump Spotless to 6.25.0 and JaCoCo to 0.8.12
- Migrate all domain entities (Article, Comment) from Joda DateTime to Instant
- Rewrite DateTimeCursor to use Instant.ofEpochMilli/toEpochMilli
- Rewrite DateTimeHandler (MyBatis TypeHandler) for java.time.Instant
- Rewrite JacksonCustomizations serializer for Instant
- Update DTOs (ArticleData, CommentData) to use Instant
- Update query services (ArticleQueryService, CommentQueryService)
- Update GraphQL datafetchers to use DateTimeFormatter
- Update all test files to use Instant
- Remove joda-time:joda-time:2.10.13 from build.gradle
…ase-0a-joda-to-java-time

Replace org.joda.time.DateTime with java.time.Instant across entire codebase
- Bump jjwt-api, jjwt-impl, jjwt-jackson to 0.12.6 in build.gradle
- Rewrite DefaultJwtService for JJWT 0.12.6 API:
  - Remove SignatureAlgorithm enum, use Jwts.SIG.HS512
  - setSubject() -> subject(), setExpiration() -> expiration()
  - signWith(key) -> signWith(key, Jwts.SIG.HS512)
  - parserBuilder().setSigningKey().build().parseClaimsJws()
    -> parser().verifyWith().build().parseSignedClaims()
  - getBody() -> getPayload()
- Extend test secret to 64 bytes (HS512 minimum enforced by 0.12.6)
…ase-0b-jjwt-upgrade

Upgrade JJWT from 0.11.2 to 0.12.6
- Update gradle-wrapper.properties to gradle-8.10.2-bin.zip
- Regenerate wrapper scripts (gradlew, gradlew.bat) and JAR
- Bump Spring Boot plugin 2.6.3 -> 2.7.18 for Gradle 8 compat
- Bump dependency-management plugin 1.0.11 -> 1.1.6
- Bump Mockito to 5.14.2 and force ByteBuddy 1.15.4 for Java 21
- Fix Spotless target to avoid implicit task dependencies
- Fix Selenium BasePage to use Duration API (Selenium 4.x)
…ase-1-gradle-upgrade

Upgrade Gradle wrapper from 7.4 to 8.10.2
…#172 (Boot 2.7.18->3.5.3) and drop Selenium downgrade (BOM-managed 4.x)
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment on lines +119 to +122
.updatedAt(
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
.withZone(ZoneOffset.UTC)
.format(comment.getCreatedAt()))

@devin-ai-integration devin-ai-integration Bot Jun 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 buildCommentResult uses getCreatedAt() for updatedAt instead of getUpdatedAt()

In CommentDatafetcher.buildCommentResult(), the updatedAt field is populated using comment.getCreatedAt() instead of comment.getUpdatedAt(). The CommentData class (src/main/java/io/spring/application/data/CommentData.java:19-20) has separate createdAt and updatedAt fields, and the database schema (src/main/resources/db/migration/V1__create_tables.sql:47-48) has distinct created_at and updated_at columns. While this is a pre-existing issue (the old Joda code had the same bug), the PR touched these lines during the migration and carried the bug forward.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and confirmed correct — updatedAt should use getUpdatedAt() (as ArticleDatafetcher does). However this is a pre-existing bug on main, not introduced here: the original Joda code was .updatedAt(ISODateTimeFormat.dateTime().withZoneUTC().print(comment.getCreatedAt())). The migration deliberately preserves wire-format/behavior (source-parity), so the joda→java.time rewrite of these lines kept the identical (buggy) behavior rather than silently changing the API response inside a large framework-upgrade PR.

Leaving it as-is to keep this PR behavior-neutral; recommending a separate small follow-up PR to fix getCreatedAt()getUpdatedAt() here (no test currently locks this — CommentsApiTest constructs CommentData with createdAt in both slots — so a fix should add a test asserting updatedAt != createdAt).

Comment on lines 23 to 27
public static class RealWorldModules extends SimpleModule {
public RealWorldModules() {
addSerializer(DateTime.class, new DateTimeSerializer());
addSerializer(Instant.class, new InstantSerializer());
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Custom Instant serializer may conflict with auto-configured JavaTimeModule

The old code registered a custom serializer for Joda's DateTime.class, which had no conflict with any auto-configured Jackson module. The new code at src/main/java/io/spring/JacksonCustomizations.java:25 registers a custom serializer for Instant.class, which conflicts with the InstantSerializer from jackson-datatype-jsr310's JavaTimeModule (auto-configured by Spring Boot 3). In practice, Spring Boot registers @Bean modules after auto-discovered ones, so the custom serializer should take precedence and produce the expected yyyy-MM-dd'T'HH:mm:ss.SSS'Z' format. However, this ordering is implicit and could break if module registration order changes in a future Spring Boot version. A more robust approach would be to configure JavaTimeModule's serialization format or explicitly disable JavaTimeModule auto-configuration.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The custom @Bean Module serializer does take precedence and the wire format is verified by a passing test: ArticleApiTest.should_get_article_success asserts article.createdAt equals DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")...format(time) (ArticleApiTest.java:78-83). That test is green in this PR, so the custom InstantSerializer is winning over JavaTimeModule's default Instant handling and the yyyy-MM-dd'T'HH:mm:ss.SSS'Z' contract is preserved.

Agreed the precedence is implicit. Since behavior is currently correct and test-locked, I'm leaving it for this migration PR; a reasonable future hardening (separate from this upgrade) would be to set the format on JavaTimeModule / JacksonProperties or register the serializer via Jackson2ObjectMapperBuilderCustomizer so the ordering is explicit rather than relying on @Bean-after-autoconfig registration.

- Bump Spring Boot plugin to 3.5.0, dependency-management to 1.1.7
- Bump DGS codegen plugin to 7.0.3, DGS starter to 10.6.0
- Set sourceCompatibility/targetCompatibility to Java 21
- Update mybatis-spring-boot-starter to 3.0.3
- Replace graphql-dgs-spring-boot-starter with graphql-dgs-spring-graphql-starter
- Upgrade REST-Assured to 5.4.0, httpclient5 to 5.4.4
- Migrate javax.servlet/javax.validation to jakarta namespace
- Rewrite WebSecurityConfig using SecurityFilterChain bean with lambda DSL
- Update GraphQL datafetchers for DGS 10.x PageInfo API
- Update GraphQLCustomizeExceptionHandler for new DataFetcherExceptionHandler API
- Update CustomizeExceptionHandler for Spring 6 HttpStatusCode parameter
- Update CI workflows to Java 21
- Add dependency resolution strategy for java-dataloader compatibility
…ase-2-boot3-java21

feat: Upgrade to Spring Boot 3.5.0 and Java 21
…ound

Ran ./gradlew dependencies for runtimeClasspath and testRuntimeClasspath
and confirmed zero javax.validation:validation-api or other
Jakarta-conflicting jars are present. The Spring Boot 3.x BOM correctly
resolves all validation deps to jakarta.validation.
…t deps

- selenium-java 4.15.0 → 4.21.0
- webdrivermanager 5.6.2 → 5.8.0
- testng 7.8.0 → 7.10.2
- extentreports 5.1.1 → 5.1.2 (latest)
…ase-3c-classpath-verification

Phase 3c: classpath verification — no Jakarta-conflicting jars found
…ase-3b-test-deps

Phase 3b: bump Selenium, WebDriverManager, TestNG, and ExtentReports test deps

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment thread build.gradle
}
// Phase 3c classpath verification: confirmed no javax.validation:validation-api
// or other Jakarta-conflicting jars on runtimeClasspath or testRuntimeClasspath.
// All validation dependencies resolve to jakarta.validation (Spring Boot 3.x BOM).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Flyway SQLite support may require additional dependency with Spring Boot 3.5

Spring Boot 3.5.0 ships with Flyway 10+ which modularized database-specific support into separate artifacts. The build only declares implementation 'org.flywaydb:flyway-core' at build.gradle:65. If the bundled Flyway version requires org.flywaydb:flyway-database-sqlite for SQLite support, the application would fail at startup. This depends on the exact Flyway version managed by the Spring Boot 3.5.0 BOM — if it's Flyway 10+, SQLite support was moved out of core. This should be verified by running the application or tests against the SQLite database.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — this does not apply to this project's usage. The Boot 3.5 BOM resolves flyway-core to 11.7.2 (no flyway-database-sqlite on the classpath), and SQLite migrations still run successfully:

  • RealworldApplicationTests.contextLoads is @SpringBootTest with the default profile, so it uses spring.datasource.url=jdbc:sqlite:dev.db (file-based) and boots the full context — which triggers Flyway auto-configuration to apply V1__create_tables.sql + V2__seed_data.sql at startup. That test passes (tests="1" failures="0" errors="0"), exercising the exact app-startup migration path you flagged.
  • The MyBatis repository tests (DbTestBase@MybatisTest, @ActiveProfiles("test"), in-memory jdbc:sqlite::memory:, spring.flyway.target=1) also apply V1 via Flyway and pass (e.g. ArticleRepositoryTransactionTest, MyBatisArticleRepositoryTest).

So Flyway 11 handles SQLite here without the separate module, and the green @SpringBootTest context-load test will catch any regression if the managed Flyway version ever drops bundled SQLite support. No change needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant