Migrating the Albertsons Cart & Checkout platform from Java 11 to Java 17 was one of those projects that sounds straightforward until you're three weeks in and debugging a InaccessibleObjectException in a library you haven't touched in four years. Java 17 is a Long-Term Support release with genuinely useful language features โ but it also brings strong encapsulation changes that will break things in ways the compiler won't warn you about.
Here's what actually mattered in a large Spring Boot microservices codebase.
The Features Worth Using
Records (Java 16+)
Records eliminate the boilerplate for immutable data carriers. We replaced dozens of DTO classes with records across our cart and checkout services:
// Before: 40 lines of boilerplate public class CartItem { private final String productId; private final int quantity; private final BigDecimal price; // constructor, getters, equals, hashCode, toString... } // After: one line public record CartItem(String productId, int quantity, BigDecimal price) {}
One gotcha: records don't work with frameworks that require a no-arg constructor (some older Hibernate versions, certain Jackson configurations). Test your serialization layer thoroughly.
Pattern Matching for instanceof
No more redundant casts after an instanceof check:
// Before if (event instanceof CheckoutEvent) { CheckoutEvent ce = (CheckoutEvent) event; process(ce.getSessionId()); } // After if (event instanceof CheckoutEvent ce) { process(ce.getSessionId()); }
Small change, surprisingly satisfying to use throughout a large codebase. Our event processing code cleaned up noticeably.
Sealed Classes
Sealed classes let you declare a closed hierarchy โ the compiler knows all subclasses and enforces exhaustiveness when switching. We used this for our cart event type hierarchy:
public sealed interface CartEvent permits ItemAddedEvent, ItemRemovedEvent, CheckoutInitiatedEvent, CartAbandonedEvent {} // Compiler now enforces all cases are handled switch (event) { case ItemAddedEvent e -> handleAdd(e); case ItemRemovedEvent e -> handleRemove(e); case CheckoutInitiatedEvent e -> handleCheckout(e); case CartAbandonedEvent e -> handleAbandoned(e); }
Text Blocks
Multiline strings without escape hell. We use these extensively in test fixtures and SQL query constants:
String query = """
SELECT c.session_id, c.items, c.updated_at
FROM carts c
WHERE c.store_id = :storeId
AND c.status = 'ACTIVE'
AND c.updated_at > :since
ORDER BY c.updated_at DESC
""";
The Breaking Changes Nobody Warned You About
Strong Encapsulation of JDK Internals
This is the big one. Java 17 enforces strong encapsulation for internal JDK APIs. Libraries that used reflection to access sun.* packages or internal fields will throw InaccessibleObjectException at runtime โ not at compile time.
We hit this with:
- Older Kryo serializers used in our Kafka message serialization layer
- Legacy ASM-based bytecode manipulation in an internal code generation library
- Mockito versions below 4.x โ if you're still on Mockito 3.x, upgrade first
Fix: add --add-opens JVM flags as a short-term workaround, then upgrade the offending libraries.
Removed APIs
Thread.stop(), Thread.suspend(), and Thread.resume() are removed. We found one legacy integration service still calling Thread.stop() โ it compiled fine on Java 11, silently failed to link on 17.
Migration Strategy That Worked
- Run jdeps and jlink analysis to identify use of internal APIs before you touch the code
- Upgrade Spring Boot to 3.x first โ it requires Java 17 and handles most framework-level compatibility
- Enable --add-opens broadly in CI, then tighten one module at a time as you fix root causes
- Migrate service by service, not all at once โ one pod running Java 17 alongside Java 11 pods is fine in a microservices setup
- Run your full integration test suite with the new JVM before any prod deployment
Key Takeaways
- Records, sealed classes, and pattern matching are genuinely useful โ prioritize adopting them in new code
- Strong encapsulation will break libraries using reflection against JDK internals; audit dependencies with jdeps before starting
- Upgrade Mockito, Spring Boot, and serialization libraries before the JVM upgrade, not after
- Migrate service by service in a microservices architecture โ big-bang JVM upgrades across all services simultaneously are high risk
- Runtime breakages won't surface at compile time โ full integration test coverage is non-negotiable