โ† All Articles

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:

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

  1. Run jdeps and jlink analysis to identify use of internal APIs before you touch the code
  2. Upgrade Spring Boot to 3.x first โ€” it requires Java 17 and handles most framework-level compatibility
  3. Enable --add-opens broadly in CI, then tighten one module at a time as you fix root causes
  4. Migrate service by service, not all at once โ€” one pod running Java 17 alongside Java 11 pods is fine in a microservices setup
  5. Run your full integration test suite with the new JVM before any prod deployment

Key Takeaways