Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Java Forum Nord 2026 - From "Effective Java" to...

Java Forum Nord 2026 - From "Effective Java" to Java 25 and more

Effective Java is one of the all-time classics in Java literature. It shaped how generations of developers learned to write better, safer, and more maintainable Java. But its last edition covered Java up to version 9 — and since then, the platform has been transformed in a way that even Joshua Bloch could not foresee back then.

In this session we will revisit a curated set of Effective Java recommendations and give each one a verdict, e.g. "still valid", "improved", "for free", or "obsolete". Which practices hold up exactly as written? Which ones got better tools? Which were replaced by new features such as records, sealed interfaces, or pattern matching? And which warnings are now the JVM’s problem — not yours?

The running example is a real Spring Boot 4 application built with hexagonal architecture. Every principle is demonstrated in working code, not just in slides of theory. Come for the classics. Leave with updated defaults and a clear sense of what “effective” actually means in today's Java. For generations to come. ❤️

Avatar for Frederieke Scheper

Frederieke Scheper

September 22, 2026

More Decks by Frederieke Scheper

Other Decks in Programming

Transcript

  1. From "Effective Java" to Java 25 and more A Fresh

    Take on the Java Classic Frederieke Scheper Java Forum Nord · September 2026
  2. Where Are You on the Java Timeline? Still on Java

    8 or 11? On Java 17 LTS? On Java 21 LTS? On Java 25 LTS? On Java 26? On Java 27 — out one week ago? Not sure what version production runs? (you are not alone) FS
  3. A Java Classic Published 2018 — baseline is Java 9

    90 items across 12 chapters — distilled experience from Joshua Bloch Still widely regarded as the best Java book ever written Java has shipped 17 releases and 250+ JEPs since then Prologue · 1 / 3 FS
  4. Today's Journey — Eight Acts ACT 1 ACT 2 Creating

    & destroying objects Factories · builders · singletons · cleaners Object methods & records Records retire equals, hashCode, clone & more ACT 3 ACT 4 Classes & interfaces Sealed types · interface evolution Generics & collections Gatherers · SequencedCollection ACT 5 ACT 6 Enums & patterns Switch expressions · record patterns Lambdas & modern idioms var · HttpClient · module imports ACT 7 ACT 8 Concurrency reloaded Virtual threads · Scoped Values Putting it all together DJ Console: every feature at once EPILOGUE & Q&A Wrap-up & what to do on Monday What aged well · what changed · your next step · questions One running example throughout — the DJ Console API Prologue · 2 / 3 FS
  5. The Running Example — DJ Console API A real Spring

    Boot 4 codebase — from my session at Spring I/O → every feature in this talk appears in its source code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Prologue · 3 / 3 // Three modern Java features — one domain model sealed interface CrowdEvent permits CrowdCheered, CrowdEnergyDropped, DancefloorFilledUp, DancefloorEmptied, RequestFromAudienceReceived { LocalDateTime occurredAt(); } public record MixSession( MixSessionId id, DiscJockey dj, List<SessionTrack> tracks, List<CrowdEvent> crowdEvents ) { public MixSession applyEvent(CrowdEvent event) { var newEvents = copyWithNewElement(crowdEvents, event); var newTracks = copyWithNewElement(tracks, decideNextTrack(event)); return new MixSession(id, dj, newTracks, newEvents); } } FS
  6. Act 1 — Creating & Destroying Objects Act 1 —

    Creating & Destroying Objects FS
  7. Item 1: Consider Static Factory Methods instead of Constructors 1

    2 3 4 5 6 7 8 9 10 11 12 Advantages Trade-offs Have a name — reveal intent Can avoid new object — return cached Can return a subtype No public or protected constructor → can't subclass No dedicated Javadoc section — harder to discover // What is the 4th arg? What does LOW mean here? new SessionTrack(sourceId, song, Duration.ofMinutes(4), EnergyLevel.LOW, cuePoints) // Name tells the story SessionTrack track = SessionTrack.fromLibrary(libraryTrack); // Returns a cached constant — never allocates public static EnergyLevel fromLevel(long round) { if (round <= 3) return LOW; if (round <= 6) return MEDIUM; return HIGH; } EJ3 naming conventions — still valid: of · from · valueOf · getInstance · newInstance Act 1 — Creating & Destroying Objects · 1 / 3 FS
  8. FS Item 2: Consider a Builder When Faced with Many

    Constructor Parameters No JEP — but Lombok @Builder makes it a one-liner Act 1 — Creating & Destroying Objects · 2 / 3 1 2 3 4 5 6 // EJ3: telescoping constructor — does not scale new Track(id, song, Duration.ofMinutes(4), EnergyLevel.HIGH, List.of(cue1, cue2)) // 5th arg: what is this again? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // EJ3 builder — Lombok generates it from one annotation @Builder(toBuilder = true, buildMethodName = "internalBuild") public record Track( @NotNull TrackId id, @NotNull Song song, @NotNull Duration length, @NotNull EnergyLevel energyLevel, List<CuePoint> cuePoints ) { ... } // JavaBeans: mutable, inconsistent state possible track.setId(id); track.setSong(song); // object half-built here Track t = Track.builder() .song(song).length(Duration.ofMinutes(4)) .energyLevel(EnergyLevel.HIGH) .build();
  9. FS Item 3: Enforce a Singleton Property with an Enum

    or Private Constructor Example 1 2 3 4 5 6 7 8 9 Act 1 — Creating & Destroying Objects · 3 / 3 Enum singleton (EJ3 Item 3) Spring @Component enum Registry { INSTANCE; } @Component DiscJockeyRegistry Thread safety JVM guarantees Container guarantees Serialisation Safe by default Not serialised Testability Hard to mock Injectable — @MockitoBean Best for Stateless constants & type-safe configs Stateful components & application beans // EJ3: single-element enum — reflection-safe, serialisation-safe enum AppMode { PRODUCTION } // one instance, forever // Spring: stateful registry — injectable, lifecycle-managed, mockable @Component public class DiscJockeyRegistry { private final Map<String, DiscJockey> byName = new HashMap<>(); // populated at startup by a SmartLifecycle loader — live state, must be injectable }
  10. Act 2 — Object Methods & Records Act 2 —

    Object Methods & Records FS
  11. Records: The Modern Value Object Items 10–13, 15–17 — solved

    by one keyword Immutable by construction — no setters, no surprises equals / hashCode / toString — auto-generated from all components Compact constructor — write only validation and normalisation private final components — accessor methods, never public fields 1 2 3 4 5 6 7 8 9 10 Act 2 — Object Methods & Records · 1 / 6 // 5 components — still one declaration, zero boilerplate: public record Track( @NotNull TrackId id, @NotNull Song song, @NotNull Duration length, @NotNull EnergyLevel energyLevel, List<CuePoint> cuePoints ) { } // No getters, no setters, no equals/hashCode/toString — the compiler provides all. FS
  12. Records: Compact Constructor Your validation hook — runs inside the

    canonical constructor 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Act 2 — Object Methods & Records · 2 / 6 // Compact constructor: no parameter list, body runs before components are assigned @ConfigurationProperties(prefix = "dj.crowd-vote") public record CrowdVoteProperties(List<Choice> choices) { public CrowdVoteProperties { // ← no (List<Choice> choices) if (choices == null || choices.size() != 3) throw new IllegalStateException("dj.crowd-vote.choices must contain exactly 3 entries"); choices = List.copyOf( // ← normalise + defensive copy choices.stream() .sorted(Comparator.comparingInt(Choice::slot)) .toList()); // compiler generates: this.choices = choices — after this body } public record Choice(int slot, String label, String trackName) {} } Use case What you write Validation if (x == null) throw new IllegalStateException(...) Normalisation name = name.strip() Defensive copy items = List.copyOf(items) None of the above Simple records — just declare components, omit the constructor FS
  13. Item 10: Obey the General Contract When Overriding equals FS

    For free on value objects — intentional on entities EJ3: hand-written — 8+ lines, easy to get wrong Record: generated — reflexive, symmetric, transitive, null-safe. Always. 1 @Override public boolean equals(Object o) { 2 if (this == o) return true; 3 if (!(o instanceof Song s)) return false; 4 return Objects.equals(id, s.id) 5 && Objects.equals(artist, s.artist) 6 && Objects.equals(title, s.title) 7 && Objects.equals(audioFile, s.audioFile); 8 } public record Song( SongId id, Artist artist, String title, String audioFile ) { } // equals() generated — compares all 4 components // Add a field → equals auto-updates. Forget nothing. But: entity records override by ID (aggregate roots have identity, not just value) 1 2 3 4 5 6 7 8 9 10 Act 2 — Object Methods & Records · 3 / 6 // MixSession is a DDD aggregate root — identity = id, not state // Test with EqualsVerifier.forClass(MixSession.class).withOnlyTheseFields("id").verify(); public record MixSession(MixSessionId id, DiscJockey dj, List<SessionTrack> tracks, ...) { @Override public boolean equals(Object o) { return o instanceof MixSession other && id.equals(other.id); } @Override public int hashCode() { return id.hashCode(); } }
  14. Item 12: Always Override toString FS EJ3 said "always override"

    — records make the default worth keeping POJO default — useless 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Act 2 — Object Methods & Records · 4 / 6 // Pre-records: a plain class, no toString() public class PojoSong { String title; Artist artist; } song.toString() // → com.example.PojoSong@7b23ec81 // FQ class name + identity hash code // EJ3: "always override toString()" @Override public String toString() { return "PojoSong[" + title + " by " + artist.name() + "]"; } Record default — readable out of the box public record Song(SongId id, Artist artist, String title, String audioFile) {} song.toString() // → Song[id=SongId[value=3fa2...], // artist=Artist[name=Queen], // title=Bohemian Rhapsody, ...] // Override when too verbose or sensitive: @Override public String toString() { return "MixSession[id=%s, dj=%s, tracks=%d, status=%s]" .formatted(id.value(), dj.name(), tracks.size(), getStatus()); }
  15. Item 13: Override clone Judiciously Records have no clone —

    use copy constructors or factory methods instead 1 2 3 4 5 6 7 8 9 Act 2 — Object Methods & Records · 5 / 6 Cloneable / clone() Copy-by-construction (records) API protected , cast required Explicit, readable Deep copy Manual, error-prone Explicit per field Immutability Hard — clone is mutable copy Records are immutable — copy IS a new value Java 26 Avoid entirely Default with records // The Cloneable pattern — don't do this class Session implements Cloneable { @Override protected Session clone() { ... } // cast, catch, weep } // Records: copy-and-modify with toBuilder() MixSession updated = session.toBuilder() .tracks(newTracks) .build(); // new immutable value — safe to share, no defensive copy needed FS
  16. Items 15–17: Minimise Accessibility; Favour Immutability FS Accessibility, immutability, minimise

    public fields → enforced by the compiler EJ3 rule What you had to write What record does Item 15 — minimise accessibility private final on every components are private final — no choice Item 16 — no public fields add accessor methods manually accessors are methods, not fields Item 17 — minimise mutability no setters, defensive copies immutable by construction — no setters possible 1 2 3 4 5 6 7 8 9 Act 2 — Object Methods & Records · 6 / 6 field // All three items enforced by the compiler: @Builder(toBuilder = true, buildMethodName = "internalBuild") public record Track( @NotNull TrackId id, // private final — cannot be changed @NotNull Song song, // accessor is method: track.song(), not track.song @NotNull Duration length, @NotNull EnergyLevel energyLevel, List<CuePoint> cuePoints // no setter — toBuilder() for copy-and-modify ) { }
  17. Item 18: Favour Composition Over Inheritance Records and sealed types

    make composition the structural default EJ3: fragile base class problem 1 // Inheritance across packages — dangerous 2 class InstrumentedHashSet<E> extends HashSet<E> { 3 int addCount = 0; 4 @Override public boolean add(E e) { 5 addCount++; 6 return super.add(e); // fine 7 } 8 9 @Override 10 public boolean addAll(Collection<? extends E> c) { 11 addCount += c.size(); 12 return super.addAll(c); // BUG: calls add() 13 } 14 15 // after addAll(List.of(1,2,3)): addCount = 6 16 } Act 3 — Classes & Interfaces · 1 / 5 DJ Console: composition is the only option // DiscJockey is a record — you cannot extend it // Composition isn't a guideline; it's structural public record MixSession( MixSessionId id, DiscJockey dj, // wrapped, not extended List<SessionTrack> tracks, List<CrowdEvent> crowdEvents ) { // behaviour added by the wrapper, not inherited public MixSession applyEvent(CrowdEvent event) { var next = dj.findNextNewTrack(...); return new MixSession( id, dj, append(tracks, next), ...); } } // No fragile base class — dj's internals are hidden FS
  18. Item 19: Design and Document for Inheritance or Else Prohibit

    It FS sealed is the precise modern way to prohibit unintended extension EJ3 advice Modern Java "Design for inheritance or prohibit it" sealed prohibits with a named whitelist, not just final Document all protected methods No protected needed — records have no mutable hierarchy final class — blunt instrument sealed — controlled, named extensions only Abstract class + instanceof chains sealed interface + exhaustive switch 1 2 3 4 5 6 7 8 9 Act 3 — Classes & Interfaces · 2 / 5 // sealed = "exactly these classes may extend — nobody else" public sealed interface CrowdEvent permits CrowdCheered, CrowdEnergyDropped, DancefloorEmptied, DancefloorFilledUp, RequestFromAudienceReceived { ... } // Records are implicitly final — two levels of protection: public record CrowdCheered(LocalDateTime occurredAt) implements CrowdEvent {} // cannot be extended further
  19. Items 20+21: Prefer Interfaces to Abstract Classes; Design for Posterity

    FS Java 8 default methods + Java 9 private methods — interfaces grew up 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Act 3 — Classes & Interfaces · 3 / 5 // Item 21: private interface method (Java 9, JEP 213) // One interface replaces an abstract class + companion utility class public interface ValidatingBuilder<T> { // Item 20: default method — shared behaviour, no abstract class needed default T build() { onSetDefaultValues(); var result = internalBuild(); var violations = VALIDATOR.validate(result); if (!violations.isEmpty()) throw new ModelValidationException(result, violations); return result; } T internalBuild(); default void onSetDefaultValues() {} // implementors provide this // optional hook // Item 21: private method — implementation detail, not API surface private static Validator getValidator() { ... } }
  20. Item 23: Prefer Class Hierarchies to Tagged Classes (1/2) record

    implements sealed interface → a closed, immutable, compiler-verified hierarchy EJ3 — tagged class 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Java 17 — sealed interface + records // One class, one tag field, instanceof chains class CrowdEvent { enum Type { CHEERED, ENERGY_DROPPED, DANCEFLOOR_EMPTIED ... } final Type type; // the "tag" String trackName; // only valid for one type! } // Closed hierarchy, each type carries its own data public sealed interface CrowdEvent permits CrowdCheered, CrowdEnergyDropped, DancefloorEmptied, DancefloorFilledUp, RequestFromAudienceReceived { // Add a Type constant → every switch misses it switch (event.type) { case CHEERED: ... case ENERGY_DROPPED: ... // DANCEFLOOR_FILLED_UP added later — falls to: default: throw new AssertionError(); // 3am } // record implements the interface — no boilerplate public record CrowdCheered(LocalDateTime occurredAt) implements CrowdEvent {} LocalDateTime occurredAt(); // shared contract } public record RequestFromAudienceReceived( String trackName, LocalDateTime occurredAt) implements CrowdEvent {} With the tagged class: add a field, break every caller silently. With sealed + record : each type is self-contained — the compiler owns the permit list. Act 3 — Classes & Interfaces · 4 / 5 FS
  21. Item 23: Prefer Class Hierarchies to Tagged Classes (2/2) Sealed

    interface + exhaustive switch: → no default branch, compiler-verified 1 // MixSession.java — exhaustive switch on sealed CrowdEvent 2 private SessionTrack decideNextTrack(CrowdEvent event) { 3 var avg = getAverageEnergyLevel(); 4 return switch (event) { // JEP 441 5 case CrowdCheered _ -> dj.findNextNewTrack(avg, tracks); 6 case CrowdEnergyDropped _ -> dj.findNextNewTrack(avg.next(), tracks); 7 case DancefloorEmptied _ -> dj.findNextNewTrackWithHighestEnergyLevel(tracks); 8 case DancefloorFilledUp _ -> dj.findNextNewTrack(avg, tracks); 9 case RequestFromAudienceReceived e -> 10 dj.findTrackByTitle(e.trackName()) 11 .orElseGet(() -> dj.findNextNewTrack(avg, tracks)); 12 }; // ← no default — add a 6th CrowdEvent → compile error here 13 } Add a 6th CrowdEvent to permits and forget to update this switch? The compiler tells you. At build time. Not at 3am. Act 3 — Classes & Interfaces · 5 / 5 FS
  22. Act 4 — Generics, Lambdas & Streams Act 4 —

    Generics, Lambdas & Streams FS
  23. Item 43: Prefer Method References to Lambdas FS Use a

    method reference when it reads more clearly → Predicate.not() (Java 11) and ::getFirst (Java 21) show it Java 9 — lambda, readable but backward 1 2 3 4 5 6 7 8 // Filter is logically "not already played" // but the lambda reads negated: .filter(t -> !alreadyPlayed.contains(t)) // ↑ double negation — reads backwards // Fallback: lambda calling another method .orElseGet(() -> trackLibrary.get(0)) // ↑ index arithmetic Current Java — method refs read as English // Java 11: Predicate.not() — negation as method ref .filter(Predicate.not(alreadyPlayed::contains)) // reads: "not contained in alreadyPlayed" // Java 21 (JEP 431): getFirst() — intent explicit .orElseGet(trackLibrary::getFirst) // reads: "first in the library" // Last lambda stays — needs alreadyPlayed arg, // method ref can't supply it: Item 43 in action .orElseGet(() -> findNextNewTrackWithHighestEnergyLevel( alreadyPlayed)); Act 4 — Generics, Lambdas & Streams · 1 / 4
  24. Item 45: Use Streams Judiciously JEP 485 (Java 24): Stream.gather()

    → stateful intermediate ops without materialising the pipeline 1 // EnergyTrendAnalyser.java — stateful sliding window gatherer 2 public static <T> Gatherer<T, ?, List<T>> slidingWindow(int n) { 3 return Gatherer.ofSequential( 4 () -> new ArrayDeque<T>(n), 5 (window, element, downstream) -> { 6 window.addLast(element); 7 if (window.size() == n) { 8 var shouldContinue = downstream.push(List.copyOf(window)); 9 window.pollFirst(); 10 return shouldContinue; // honour downstream short-circuit 11 } 12 return true; 13 }); 14 } 1 2 3 4 5 6 7 8 9 Act 4 — Generics, Lambdas & Streams · 2 / 4 // Using the gatherer — EJ3 Item 45: streams where they enhance clarity public static boolean isEnergyRising(List<SessionTrack> tracks, int window) { return tracks.stream() .gather(slidingWindow(window)) // stateful intermediate op — JEP 485 .anyMatch(w -> w.getLast().energyLevel().getIntensity() > w.getFirst().energyLevel().getIntensity()); } // [LOW, LOW, HIGH] window=2 → [LOW,LOW], [LOW,HIGH] → rising = true FS
  25. Item 47: Prefer Collection to Stream as a Return Type

    Java 16: Stream.toList() — immutable terminal op, one expression replaces collect(toList()) Before Java 16 — verbose, mutable // mutable ArrayList — callers can modify! List<SessionTrack> tracks = library.stream() .map(SessionTrack::fromLibrary) .collect(Collectors.toList()); Java 16+ — concise, unmodifiable // unmodifiable — contract in the name List<SessionTrack> tracks = library.stream() .map(SessionTrack::fromLibrary) .toList(); // From MusicLibraryLookupImpl.allSessionTracks(): return library().tracks().stream() .map(SessionTrack::fromLibrary) .toList(); Act 4 — Generics, Lambdas & Streams · 3 / 4 FS
  26. Item 64: Refer to Objects by Their Interfaces FS JEP

    431 (Java 21): SequencedCollection → getFirst(), getLast(), reversed() — no index arithmetic Pre-Java 21 — error-prone index math Java 21 — SequencedCollection // every developer has written this at least once var last = events.get(events.size() - 1); var first = sorted.get(0); // MixSession.Status.of() — reads as intent var last = events.getLast(); var first = sorted.getFirst(); // easy to get wrong, easy to miss in review var prev = list.get(list.size() - 2); // DiscJockey.findNextNewTrackWithHighestEnergyLevel(): return sorted.stream() .filter(Predicate.not(alreadyPlayed::contains)) .findFirst() .orElseGet(trackLibrary::getFirst); // JEP 431 Act 4 — Generics, Lambdas & Streams · 4 / 4
  27. Act 5 — Enums & Pattern Matching Act 5 —

    Enums & Pattern Matching FS
  28. Item 34: Use Enums Instead of int Constants JEP 361

    (Java 14): switch expressions make enums even more powerful 1 public enum EnergyLevel { 2 LOW(1), MEDIUM(5), HIGH(10); 3 4 private final int intensity; 5 6 EnergyLevel(int intensity) { this.intensity = intensity; } 7 public int getIntensity() { return intensity; } 8 9 // Static factory — maps numeric range to enum constant 10 public static EnergyLevel fromLevel(long level) { 11 if (level <= 3) return LOW; 12 if (level <= 7) return MEDIUM; 13 return HIGH; 14 } 15 16 // next() uses switch expression — shown on the next slide 17 } Act 5 — Enums & Pattern Matching · 1 / 4 FS
  29. Switch Expressions — Exhaustive & No Fall-Through FS JEP 361

    (Java 14): switch as an expression → arrow cases, no fall-through, compiler-verified exhaustiveness Before — statement switch, fall-through risk Java 14+ — expression switch, compiler-enforced // Item 34: old switch hides omissions switch (this) { case LOW: return MEDIUM; // easy to miss break case MEDIUM: case HIGH: return HIGH; // on-purpose fall-through // but nothing checks it } // EnergyLevel.next() — exhaustive, no default public EnergyLevel next() { return switch (this) { case LOW -> MEDIUM; case MEDIUM, HIGH -> HIGH; // all cases checked }; // no default needed } // MixSession.Status.of() — sealed type, no default return switch (events.getLast()) { case CrowdCheered _ -> Status.PEAK; case DancefloorFilledUp _ -> Status.WARM_UP; case DancefloorEmptied _, CrowdEnergyDropped _, RequestFromAudienceReceived _ -> Status.COOL_DOWN; }; Act 5 — Enums & Pattern Matching · 2 / 4
  30. Pattern Matching for switch — Type, Guard, Bind FS JEP

    441 (Java 21): switch on type, narrow with when , bind in the case arm Before — instanceof chains 1 2 3 4 5 6 7 8 9 10 11 Act 5 — Enums & Pattern Matching · 3 / 4 // Pre-Java 21: manual cast, no exhaustiveness if (event instanceof CrowdCheered) { CrowdCheered e = (CrowdCheered) event; return dj.findNextNewTrack(avg, tracks); } else if (event instanceof CrowdEnergyDropped) { // ... } else { throw new AssertionError(); // at runtime } Java 21 — type pattern + guard + binding // Type pattern: match AND bind in one step // → next slide: inline destructure (record patterns) case RequestFromAudienceReceived e -> dj.findTrackByTitle(e.trackName()) // no cast .orElseGet(() -> dj.findNextNewTrack(avg, tracks)); // Guard clause — `when` refines the same type case CrowdEnergyDropped e // ← type matched when avg == EnergyLevel.HIGH -> // ← guard dj.findNextNewTrackWithHighestEnergyLevel(tracks); case CrowdEnergyDropped _ -> // ← no guard: fallback dj.findNextNewTrack(avg.next(), tracks);
  31. Record Patterns — Destructure in the Case Arm FS JEP

    440 (Java 21): destructure → extract a record's components inside the case pattern itself, not after it Named binding — match, then extract // case arm: matches type, binds whole record case RequestFromAudienceReceived e -> // ↑ 'e' = the record; accessors afterwards dj.findTrackByTitle(e.trackName()) .orElseGet(...); Record pattern — match and extract at once // case arm: matches type AND names components case RequestFromAudienceReceived(String name, _) -> dj.findTrackByTitle(name) // bound, no accessor .orElseGet(...); // Nested: destructure a SessionTrack's embedded Song case SessionTrack(_, Song(_, _, String title, _), _, _, _) -> dj.findTrackByTitle(title); // from nested record Act 5 — Enums & Pattern Matching · 4 / 4
  32. Act 6 — Methods & Modern Idioms Act 6 —

    Methods & Modern Idioms FS
  33. Item 55: Return Optionals Judiciously FS The rules are unchanged

    — just small convenience updates Before — null: invisible contract // null return: every caller must null-check public SessionTrack findTrackByTitle(String title) { ... return null; // absence? exception? unclear } // caller forgets → NullPointerException var track = dj.findTrackByTitle("Vortex"); dj.queueTrack(track); // NPE if not found Item 55 — Optional: absence is explicit // Return type encodes "might not exist" public Optional<SessionTrack> findTrackByTitle(String title) { return trackLibrary.stream() .filter(t -> t.song().title() .equalsIgnoreCase(title)) .findFirst(); } // Call site — graceful fallback, no null check dj.findTrackByTitle(e.trackName()) .orElseGet(() -> dj.findNextNewTrack(avg, tracks)); Why judiciously? — the rules that keep Optional safe Return type only — signals "absence is expected here" Never as field, parameter, or collection element — allocation overhead, serialization issues Never wrap containers — return List<T> , not Optional<List<T>> Never Optional.get() without isPresent() — use orElse / orElseGet / orElseThrow Java 11: isEmpty() replaces !isPresent() — reads positively, no double negation Act 6 — Methods & Modern Idioms · 1 / 5
  34. Item 57: Minimize the Scope of Local Variables FS var

    (Java 10) removes declaration noise without changing scope Verbose — type repeated, scope unchanged MixSessionServiceImpl service = new MixSessionServiceImpl(repo, dj); var (Java 10) — type inferred, same scope var service = new MixSessionServiceImpl(repo, dj); var grouped = new HashMap<String, List<SessionTrack>>(); Map<String, List<SessionTrack>> grouped = new HashMap<String, List<SessionTrack>>(); // intent buried in boilerplate Act 6 — Methods & Modern Idioms · 2 / 5 // applyEvent() — three vars, reads like a sentence var newEvents = copyWithNewElement(crowdEvents, event); var nextTrack = decideNextTrack(event); var newTracks = copyWithNewElement(tracks, nextTrack);
  35. Item 59: Know and Use the Libraries JEP 321 (Java

    11): HttpClient — production quality & no dependencies JEP 517 (Java 26): HTTP/3 over QUIC — same API, zero code change 1 2 3 4 5 6 7 8 9 10 11 12 13 // DjConsoleClientDemoTest — zero third-party HTTP dependency import module java.net.http; // HttpClient, HttpRequest, HttpResponse (JEP 511) @Test void getCurrentSession_withJdkHttpClient() throws Exception { var client = HttpClient.newHttpClient(); var request = HttpRequest.newBuilder() .uri(URI.create("http://localhost:" + port + "/api/sessions/current")) .GET().build(); var response = client.send(request, HttpResponse.BodyHandlers.ofString()); assertThat(response.statusCode()).isEqualTo(200); // no Apache HttpClient needed } 1 // Async variant — fires 3 concurrent requests 2 var futures = IntStream.range(0, 3) 3 .mapToObj(_ -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString())) 4 .toList(); 5 // Java 26 (JEP 517): HTTP/3 over QUIC — same API, zero code change Act 6 — Methods & Modern Idioms · 3 / 5 FS
  36. Item 63: Beware the Performance of String Concatenation JEP 378

    + JEP 280: readability and performance both solved EJ3 Item 63 — two complaints 1 2 3 4 5 6 7 8 9 10 11 12 Act 6 — Methods & Modern Idioms · 4 / 5 // 1. Multi-line strings: escape hell String q = "{\n" + " mixSession {\n" + " id\n" + " dj { name }\n" + " }\n" + "}"; // 2. Concatenation builds many String objects String msg = "Session " + id + " has " + tracks.size() + " tracks"; // → quadratic time in loop Java 15 & Java 9 — both fixed // JEP 378: text block — indentation auto-stripped String query = """ { mixSession { id dj { name } tracks { song { title } energyLevel } } } """; // JEP 280 (Java 9): + is now invokedynamic // No StringBuilder needed for simple concat return "MixSession[id=%s, tracks=%d]" .formatted(id.value(), tracks.size()); FS
  37. Item 75: Include Failure-Capture Information in Detail Messages FS JEP

    358 (Java 14): the JVM now names what was null — zero code change Before Java 14: NullPointerException at MixSession.java:114 — no context available Java 14+: "Cannot invoke List.getFirst() because DiscJockey.trackLibrary() is null" Zero code change — the JVM generates the message at runtime The GH-2 bug ( trackLibrary NPE in seed loading) would have been instantly obvious NPE at Act 6 — Methods & Modern Idioms · 5 / 5 Before JEP 358 After JEP 358 NullPointerException "Cannot invoke List.getFirst() because DiscJockey.trackLibrary() is null" dj.trackLibrary().getFirst() (no detail) Time to diagnose Minutes to hours Seconds Code change needed Add null check + log None
  38. Item 80: Prefer Executors, Tasks, and Streams to Threads FS

    JEP 444 (Java 21): virtual threads — one per request, millions at a time, one property to enable Spring Boot 4 — one property Service code — completely unaware of threads # application.yaml spring: threads: virtual: # JEP 444 — one virtual thread per HTTP request enabled: true @Service @Transactional public class MixSessionServiceImpl { public MixSession applyCrowdCheered(UUID id) { var session = getSessionById(id); var updated = session.applyEvent( new CrowdCheered(LocalDateTime.now())); // blocks on I/O — the virtual thread yields return repository.save(updated); } } Same API when you need an executor try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> repository.save(updated)); } Act 7 — Concurrency Reloaded · 1 / 3
  39. Item 81: Prefer Concurrency Utilities to wait and notify FS

    EJ3 Item 81: still valid in 2026 — virtual threads run on top of these utilities, they don't replace them java.util.concurrent — the coordination layer: queues, locks, futures, barriers Never use wait() / notify() directly — prefer higher-level abstractions Reactor Sinks.Many — thread-safe event broadcasting (DJ Console real-time updates) @Transactional — Spring coordinates JDBC transaction thread safety CompletableFuture — async orchestration without raw threads Virtual threads are the execution model; utilities provide the coordination model — they compose // MixSessionUpdatePublisher — Reactor Sinks, no synchronized, no wait/notify private final Sinks.Many<MixSession> sink = Sinks.many().multicast().onBackpressureBuffer(); public void publish(MixSession session) { sink.tryEmitNext(session); // thread-safe — Reactor handles coordination } Act 7 — Concurrency Reloaded · 2 / 3
  40. Scoped Values — Replace ThreadLocal FS JEP 506 (Java 25):

    ScopedValue — immutable, auto-scoped, inheritable; safe with virtual threads 1 2 3 4 5 6 7 8 9 10 11 ThreadLocal — requires manual cleanup ScopedValue (JEP 506, Java 25) — auto-scoped, immutable static final ThreadLocal<DiscJockey> DJ = new ThreadLocal<>(); public final class DjSessionContext { public static final ScopedValue<DiscJockey> CURRENT_DJ = ScopedValue.newInstance(); DJ.set(dj); public static <T> T runAs( DiscJockey dj, Callable<T> task) throws Exception { return ScopedValue.where(CURRENT_DJ, dj) .call(task::call); // auto-released — no remove(), no finally } try { doWork(); } finally { DJ.remove(); // forget = memory leak } } Act 7 — Concurrency Reloaded · 3 / 3
  41. Act 8 — Putting It All Together Act 8 —

    Putting It All Together FS
  42. applyEvent() — One Method, Every Rule Java 26 → The

    compiler enforces what EJ3 asked you to do by hand. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Act 8 — Putting It All Together · 1 / 3 // MixSession.java public MixSession applyEvent(CrowdEvent event) { var newEvents = copyWithNewElement(crowdEvents, event); // var (JEP 286) var newTracks = copyWithNewElement(tracks, decideNextTrack(event)); return new MixSession(id, dj, newTracks, newEvents); // copy-by-construction } // MixSession.java — private helper called by applyEvent() private SessionTrack decideNextTrack(CrowdEvent event) { var averageLevel = getAverageEnergyLevel(); return switch (event) { // pattern switch (JEP 441) case CrowdCheered _ -> dj.findNextNewTrack(averageLevel, tracks); // unnamed _ (JEP 456) case CrowdEnergyDropped _ -> dj.findNextNewTrack(averageLevel.next(), tracks); // .next() case DancefloorEmptied _ -> dj.findNextNewTrackWithHighestEnergyLevel(tracks); case DancefloorFilledUp _ -> dj.findNextNewTrack(averageLevel, tracks); case RequestFromAudienceReceived e -> dj.findTrackByTitle(e.trackName()) // Optional (Item 55) .orElseGet(() -> dj.findNextNewTrack(averageLevel, tracks)); }; } FS
  43. Hexagonal Architecture — Domain at the Centre Ports and adapters

    — enforced by ArchUnit: domain has zero Spring/JPA imports · domain types must be records Domain DiscJockey - record Application MixSession - record CrowdEvent - sealed EnergyLevel - enum Primary Adapters Spring GraphQL Controller Ports MixSessionServiceImpl @Transactional Secondary Adapters MixSessionRepository MixSessionRepositoryImpl JPA + JSONB MusicLibraryLookup MusicLibraryLookupImpl JPA + JSONB REST Controller Act 8 — Putting It All Together · 2 / 3 FS
  44. Modern Java — One Codebase, Every Feature One codebase, every

    feature → language, libraries, and concurrency all reinforcing Effective Java Theme Modern Java answer Proof in DJ Console API Creation Static factories + builders SessionTrack.fromLibrary() , ValidatingBuilder<T> Value objects Records + compact constructors Song , Track , MixSession Closed domains Sealed interface + pattern switch CrowdEvent , applyEvent() Control flow Switch expressions + record patterns EnergyLevel.next() , RequestFromAudienceReceived Collections Stream.toList() + sequenced access MusicLibraryLookupImpl , tracks.getLast() Predicate.not() + DiscJockey , DjConsoleClientDemoTest Libraries HttpClient + HTTP/3 Concurrency Act 8 — Putting It All Together · 3 / 3 Virtual threads + Scoped Values spring.threads.virtual.enabled , DjSessionContext FS
  45. Items That Aged Perfectly Some items didn't need updating —

    they were just right Item 1 — static factories still win over constructors Item 9 — Prefer try-with-resources to try-finally; Java 9 made it more flexible Item 26 — Don't use raw types: the compiler — and ArchUnit — are unforgiving Item 34 — prefer enums over int constants: switch expressions made them even more powerful Items 42+43 — lambdas and method references: Predicate.not extended the idiom Item 55 — return optionals judiciously: isEmpty() (Java 11) — the rule is unchanged Item 81 — concurrency utilities over wait/notify: virtual threads double down on this Epilogue · 1 / 3 FS
  46. If Bloch Wrote It Today The philosophy is intact →

    the boilerplate is gone, the safety is compiler-enforced Chapter 2 would open with record — not with Creating and Destroying Objects Chapter 3 would be two pages — records generate equals, hashCode, toString Chapter 4 would lead with sealed — the design tool the chapter was implicitly asking for Chapter 11 would be rewritten around virtual threads — the concurrency model has changed The 90 items would shrink — but the philosophy would not change "Make the right thing easy, the wrong thing hard, and the impossible thing clear." Epilogue · 2 / 3 FS
  47. What to Do on Monday FS Pick one. Do it

    this week. On Java 8/11 — file the LTS upgrade story this sprint — it's the unlock for everything else On Java 17 — schedule the Java 21 upgrade — one LTS hop; pattern matching switch is the reward On Java 21+ — enable spring.threads.virtual.enabled=true — one line, ship it today On Java 25/26 — replace ThreadLocal with ScopedValue & imports with import module Next new class — reach for record first — fall back to a class only when you need mutation Next fixed hierarchy — write sealed interface and let the compiler enforce exhaustiveness Read EJ3 again — it reads differently now that Java encoded most of it into the language Epilogue · 3 / 3