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
@Valueclasses — leave them alone. They are project policy for immutable domain objects and are deliberately not records. Examples: StreamType.Extension, StreamFilename. Optionalreturned by the JDK or third-party libraries (Spring DataJpaRepository.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.
RestJavaRequestDTOs 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:
- Nested key record:
PathKeyin S3StreamFileProvider.java:183. - API response shape with methods on the record: PrometheusApiClient.java:55-73.
- Multi-table projection: NetworkNodeDto.java.
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
Optionalallocation 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
@NullMarkedto apackage-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:
// SPDX-License-Identifier: Apache-2.0
@NullMarked
package org.hiero.mirror.<module>.<sub>;
import org.jspecify.annotations.NullMarked;
Conversion pattern:
// before — using Optional in code we own
public Optional<Foo> findOne(long id) { ... }
// caller:
foo.findOne(id).orElseThrow();
foo.findOne(id).ifPresent(this::handle);
// after — package or class is @NullMarked
public @Nullable Foo findOne(long id) { ... }
// caller:
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.
// before
private String name; // implicitly null until set
private List<Foo> items; // implicitly null
private Long count; // implicitly null; boxed Long
// after
private String name = "";
private List<Foo> items = List.of();
private long count = 0L; // primitive, can't be null
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.
// before
public @Nullable List<Foo> getFoos() {
return result == null ? null : result;
}
// caller:
final var foos = getFoos();
if (foos != null) {
for (var foo : foos) { ... }
}
// after
public List<Foo> getFoos() {
return result == null ? List.of() : result;
}
// caller:
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.
// before — Long forces every caller to null-check; null and 0L mean the same thing here
private Long timestamp;
private Boolean enabled;
private Integer retryCount;
// after
private long timestamp; // 0L when unset is fine
private boolean enabled; // false when unset is fine
private int retryCount; // 0 when unset is fine
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 finalwhenever not reassigned post-construction. - Parameters:
finalwhen not reassigned in the method body. - Locals:
final(orfinal 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).
web3per-call paths (everyeth_call/ EVM execution).grpcper-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); // encode
HexFormat.of().parseHex(hexString); // decode
HexFormat.of().withPrefix("0x").formatHex(b); // 0x-prefixed
HexFormat.of().withUpperCase().formatHex(b); // uppercase
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 |
for (X x : xs) { ... } |
@NullMarked repeated on every class |
@NullMarked once in package-info.java |
Common mistakes
- Converting a Lombok
@Valueclass to a record — project policy: don't. - Converting a
RestJavaRequestDTO to a record —RequestParameterArgumentResolverrequires a no-arg constructor. - Reshaping a library
Optionalreturn to@Nullable— only applies to types we own. - Marking
@NullMarkedon every method individually when the whole package could be marked at once. - Returning
nullfrom a collection-returning method — returnList.of()/Set.of()/Map.of()so callers can iterate without a null guard. - Using
Long/Integer/Booleanwhen 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
Stringto"", collections toList.of()/Set.of()/Map.of(), numeric primitives to0/0L/0.0, booleans tofalse. 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 ingrpc— use a plainfor. .collect(Collectors.toList())— use.toList().- Forgetting
import org.jspecify.annotations.NullMarked;/Nullable;— they aren't auto-imported. - Using
final varfor 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@NullMarkedscope fails the build../gradlew :<module>:test— module's unit and integration tests still pass.