| name | modernize-java |
| description | Use when reviewing, refactoring, or writing Java code in hiero-mirror-node to apply Java idioms and project conventions. Prefers records over hand-written POJOs (but never converts Lombok @Value classes), text blocks, switch expressions, sealed types, JSpecify @NullMarked over Optional in code we own with non-null defaults where reasonable (primitives over boxed types when null isn't meaningful, `List.of()` / `Set.of()` / `Map.of()` over null collection returns, sensible default values for fields), immutability (final fields/parameters/variables and `final var` for non-primitive locals where the RHS type is clear), java.util.HexFormat and java.util.Base64 over hand-rolled or Apache Commons equivalents, and avoids streams in hot paths (importer per-record loops, web3 per-call, grpc per-message). Triggers on "modernize this", "convert to Java 25", "use newer Java features", or while reviewing recently-changed Java for upgrade opportunities. |
Java Modernization
Overview
Apply Java idioms and hiero-mirror-node conventions when writing or refactoring Java in this repo. The Gradle build targets Java 25 and JSpecify + NullAway are wired into errorprone at error severity with OnlyNullMarked=true (java-conventions.gradle.kts:62-68) — so nullability mistakes inside @NullMarked scopes fail the build.
Modernize code that is already in motion (a file you're touching for another reason, a class under review). Don't go on a campaign to rewrite untouched files just to apply these rules.
When NOT to apply
- Lombok
@Value classes — leave them alone. They are project policy for immutable domain objects and are deliberately not records. Examples: StreamType.Extension, StreamFilename.
Optional returned by the JDK or third-party libraries (Spring Data JpaRepository.findById, Stream.findFirst, Optional.ofNullable, etc.) — don't reshape signatures we don't own. The "no Optional" rule applies only to types we own.
- Generated code — OpenAPI, jOOQ, GraphQL, protobuf. Never edit; the build regenerates it.
- Already-merged Flyway SQL migrations — append-only.
RestJavaRequest DTOs bound by @RequestParameter — the RequestParameterArgumentResolver constructs DTOs via the no-arg constructor, so they must stay as Lombok POJOs (see rest-api-conversion skill).
Modernizations
Records over hand-written POJOs
Use record FooBar(...) {} for plain immutable carriers we own — service layer response objects, internal DTOs, multi-table projections, multi-key cache keys, etc.
Examples in the repo:
Don't convert: Lombok @Value domain classes; request DTOs bound by @RequestParameter (see above).
Text blocks for multi-line strings
Use """...""" triple-quoted strings for any literal with embedded newlines — particularly SQL inside @Query / @UpsertColumn annotations and JSON fixtures in tests. Avoid "foo\n" + "bar\n" concatenation.
Examples: EntityRepository.java:19-28, FileDataRepository.java:14-33, AbstractTokenAccount.java:36-40.
Switch expressions
Prefer arrow-form switch (x) { case A -> ...; default -> ...; } returning a value, over old switch statements with fall-through. Pairs naturally with sealed hierarchies for exhaustive pattern matching.
Examples: RangeOperator.java:52-57, GraphQlUtils.java:37-45, CommonMapper.java:97-110.
Sealed interfaces and classes
When a hierarchy is closed (a fixed list of subtypes), declare it sealed ... permits .... The compiler then enforces exhaustive switch over it and rejects new subtypes added without updating the permits list.
Examples: EntityIdParameter.java:7, TransactionIdOrHashParameter.java:8.
JSpecify nullability over Optional for code we own
We use JSpecify (@NullMarked, @Nullable) instead of Optional for return types, fields, and parameters in code we own. NullAway runs at error severity with OnlyNullMarked=true, so within a @NullMarked scope the compiler enforces nullness at every call site. Reasons:
- Avoids per-call
Optional allocation overhead — significant in hot paths.
- Type system enforces nullness without a wrapper.
- Less ceremony at the call site (
if (x != null) vs. .orElseThrow() / .ifPresent(...)).
Where to put @NullMarked — apply it as broadly as possible, in this order of preference:
- Package (most preferred) — add
@NullMarked to a package-info.java. Existing examples: restjava/repository/package-info.java, restjava/converter/package-info.java, restjava/service/package-info.java, importer/reader/block/package-info.java.
- Class — only when a single class needs marking and converting the whole package would balloon the diff. Example: S3StreamFileProvider.java:41.
- Method — last resort, for a single odd-one-out signature.
Within a @NullMarked scope, use @Nullable on individual fields, parameters, and return types that may be null. Example: ContractSlotId.java:26-32.
A package-info.java for a new package looks like:
@NullMarked
package org.hiero.mirror.<module>.<sub>;
import org.jspecify.annotations.NullMarked;
Conversion pattern:
public Optional<Foo> findOne(long id) { ... }
foo.findOne(id).orElseThrow();
foo.findOne(id).ifPresent(this::handle);
public @Nullable Foo findOne(long id) { ... }
final var result = foo.findOne(id);
if (result == null) {
throw new NotFoundException();
}
if (result != null) {
handle(result);
}
Don't change Optional returned by JDK or third-party libraries (Stream.findFirst, etc.) — leave call sites that already use .orElse(...) / .map(...) against those.
Prefer non-null over @Nullable where reasonable
@Nullable is a tool, not a habit. When a field, return value, or parameter has a sensible non-null default, use it — callers don't have to null-check, and there's no boxing or wrapper allocation. Reach for @Nullable only when null actually carries meaning distinct from an empty / zero / false value.
Fields — initialize to a sensible default rather than leaving them implicitly null.
private String name;
private List<Foo> items;
private Long count;
private String name = "";
private List<Foo> items = List.of();
private long count = 0L;
For Lombok @Builder, mark the field with @Builder.Default so the default applies when the builder caller omits it.
Collection-returning methods — return List.of() / Set.of() / Map.of() instead of null.
Callers can then iterate, stream, or isEmpty()-check without a guard. Same applies to method parameters typed as collections — accept an empty collection rather than allowing null.
public @Nullable List<Foo> getFoos() {
return result == null ? null : result;
}
final var foos = getFoos();
if (foos != null) {
for (var foo : foos) { ... }
}
public List<Foo> getFoos() {
return result == null ? List.of() : result;
}
for (var foo : getFoos()) { ... }
Primitives over boxed types when null isn't meaningful. Each Long / Integer / Boolean is a heap allocation and a potential NullPointerException on unboxing — use long / int / boolean (or double, short, byte, char) when zero / false is a valid default and null doesn't add information.
private Long timestamp;
private Boolean enabled;
private Integer retryCount;
private long timestamp;
private boolean enabled;
private int retryCount;
Keep boxed types when null carries meaning — for example a JPA / Spring Data column that is genuinely nullable in the database (a missing value is distinct from 0), or a JSON field where absent and zero must be distinguished.
Immutability by default
- Fields:
private final whenever not reassigned post-construction.
- Parameters:
final when not reassigned in the method body.
- Locals:
final (or final var) when not reassigned.
- Collections: prefer
List.of(...), Map.of(...), Set.of(...), List.copyOf(...) over mutable builders when the value's lifetime is short and ownership is not handed off.
final var for non-primitive locals
Use final var x = expr; for local variables when the RHS makes the type obvious — constructor calls, builders, well-named factory methods, fluent-call results. Prefer a written-out type for primitives (final long timestamp = ...;) so the reader sees the exact width.
Examples: RangeOperator.java:42,61, GenericControllerAdvice.java:89.
Streams — fine outside hot paths
Streams are idiomatic for one-shot collection transforms. Avoid them in hot paths:
- Importer per-record loops (each entry of a record-stream batch).
web3 per-call paths (every eth_call / EVM execution).
grpc per-message paths (every streamed HCS message).
Lambda boxing, iterator allocation, and pipeline overhead show up under load there. A plain for (X x : xs) { ... } is the right choice.
When the result of a stream is a Collection, prefer Stream.toList() over .collect(Collectors.toList()) — shorter, returns an unmodifiable list, no extra import.
java.util.HexFormat for hex encoding
Use HexFormat.of() (and friends) for hex encoding/decoding. Replace hand-rolled String.format("%02x", b) loops, custom hex codecs, and Apache Commons Hex.encodeHexString(...).
HexFormat.of().formatHex(bytes);
HexFormat.of().parseHex(hexString);
HexFormat.of().withPrefix("0x").formatHex(b);
HexFormat.of().withUpperCase().formatHex(b);
java.util.Base64 for Base64
Use Base64.getEncoder() / Base64.getDecoder() (or getUrlEncoder / getMimeEncoder) for Base64. Replace any third-party Base64 still in the codebase.
Quick reference
| Old / verbose | Java 25 idiom |
|---|
| Hand-written immutable POJO we own | record |
"foo\n" + "bar\n" SQL string | """...""" text block |
switch-statement returning a value | switch-expression with case ... -> |
Optional<Foo> return in our code | @Nullable Foo under @NullMarked |
@Nullable Long count (null means 0) | long count = 0L; |
@Nullable Boolean enabled (null means false) | boolean enabled = false; |
return null; from a collection-returning method | return List.of(); (or Set.of() / Map.of()) |
private String name; (implicitly null) | private String name = ""; |
private List<Foo> items; (implicitly null) | private List<Foo> items = List.of(); |
| Closed type hierarchy | sealed ... permits ... |
String.format("%02x", b) loop | HexFormat.of().formatHex(bytes) |
Apache Commons Hex.encodeHexString(b) | HexFormat.of().formatHex(bytes) |
Apache Commons Base64 | java.util.Base64 |
int x = ...; x = ...; reassigned needlessly | final int x = ...; |
var x = repo.findOne(); | final var x = repo.findOne(); |
.collect(Collectors.toList()) | .toList() |
stream().forEach(...) in importer hot path |
Common mistakes
- Converting a Lombok
@Value class to a record — project policy: don't.
- Converting a
RestJavaRequest DTO to a record — RequestParameterArgumentResolver requires a no-arg constructor.
- Reshaping a library
Optional return to @Nullable — only applies to types we own.
- Marking
@NullMarked on every method individually when the whole package could be marked at once.
- Returning
null from a collection-returning method — return List.of() / Set.of() / Map.of() so callers can iterate without a null guard.
- Using
Long / Integer / Boolean when null isn't meaningful — use the primitive (long / int / boolean) with a default value. Keep the boxed type only when null and zero/false are semantically distinct (e.g. a nullable DB column).
- Leaving a field implicitly null when a sensible default exists — initialize
String to "", collections to List.of() / Set.of() / Map.of(), numeric primitives to 0 / 0L / 0.0, booleans to false. For Lombok @Builder, pair the default with @Builder.Default.
- Putting a stream in a per-record loop in the importer, per-call code in
web3, or per-message code in grpc — use a plain for.
.collect(Collectors.toList()) — use .toList().
- Forgetting
import org.jspecify.annotations.NullMarked; / Nullable; — they aren't auto-imported.
- Using
final var for primitives — prefer the written-out type so the reader sees the width.
- Editing generated code (OpenAPI / jOOQ / GraphQL / protobuf) — never; the build regenerates it.
Verification
./gradlew :<module>:spotlessApply — applies palantirJavaFormat / prettier.
./gradlew :<module>:build — passes errorprone + NullAway. NullAway runs at error severity, so any nullability mistake inside a @NullMarked scope fails the build.
./gradlew :<module>:test — module's unit and integration tests still pass.