Null-safety in Java applications
"JSpecify @NonNull vs Lombok @NonNull"
Today, my focus is on null-safety in Java applications — specifically on two orthogonal approaches to dealing with NPEs in production: ageing Lombok’s @NonNull lombok.NonNull and arising JSpecify’s @NonNull org.jspecify.annotations.NonNull.
Unlike modern programming languages such as Kotlin or TypeScript, which have null-safety built into the type system (Type | null, safe call ?.), Java — even version 25 (current LTS version at the moment of writing) — still doesn’t. There is a draft JEP that explores adding proper null-safety to the language, but it’s only a proposal. Nothing has been accepted or shipped yet, and the design is still very early.
Attempts to fix this in Java go back years: first JSR-305, then proprietary annotations (by JetBrains, Micronaut, Eclipse, Spring, etc.), and now the emerging JSpecify standard. Spring Framework has already adopted JSpecify starting with version 7 (GA Nov 2025). Spring Boot 4 also includes these annotations. For many teams, that alone will be a strong reason to adopt it in new projects or plan migration.
But first, let’s have a deeper look at how those annotations are supposed to help in dealing with NPE.
Lombok’s runtime checks#
Lombok took an earlier, pragmatic route. Its @NonNull annotation triggers code generation at compilation time that inserts guard statements — checking for null at runtime and throwing exceptions early. Lombok’s @NonNull only works where Lombok can actually insert code — in the body of a generated method or constructor. If you put it on an interface method parameter, nothing happens, because interfaces have no implementation to modify. In other words, @NonNull is meaningful only on methods or constructors of concrete classes; on interfaces it’s a no-op.
This follows the “throw early” part of the old principle:
“Throw early, catch late.”
That way, it stops invalid null values at the method boundary, throwing an immediate, clear NPE before any logic runs. This prevents bad state from propagating deeper into the call stack and makes debugging far easier.
Interestingly, Lombok’s own early docs warned:
“WARNING: If the Java community ever decides on supporting a single @NonNull annotation (for example via JSR-305), then this annotation will be deleted from the lombok package. If the need to update an import statement scares you, you should use your own annotation named @NonNull instead of this one.”
JSpecify’s dev time warnings#
JSpecify is the modern, unified and standardised approach to null-safety at development time. As its documentation states, “JSpecify is developed by consensus of major stakeholders in Java static analysis”. It fills the gap left by the abandoned JSR-305 effort and finally provides the standardized nullability layer that Lombok’s own documentation anticipated years ago. JSpecify defines ony four annotations, including @NonNull and @Nullable, both with runtime retention. Runtime retention is used not because the annotation performs runtime checks, but because many frameworks inspect annotations via reflection. Keeping nullability annotations at runtime ensures they remain visible to DI containers, serializers, validators, and other reflection-driven tooling.
These annotations are recognized by static analyzers and modern IDEs, and with proper build plugins (NullAway (Uber), Checker Framework etc.), you can even fail the build on nullability violations, following the principle:
“Bad code should never compile.”
Because no code needs to be injected, JSpecify annotations are valid anywhere Java allows annotations on types, including: interface methods, abstract/concrete class methods, params of default methods, etc.
However, note that the javac compiler itself ignores them — they work only through tools and IDE inspections.
My strategy for new codebases#
Having discussed the retrospective, the pros and cons of existing solutions, and current trends, my suggested strategy is as follows:
- API contracts: Adopt JSpecify’s null-safe defaults by placing @NullMarked in package-info.java, making all parameters and return types non-null unless explicitly marked
@Nullable. This provides strong static analysis guarantees and keeps method signatures clean and predictable. - Runtime clauses: Use rarely, only as a last-ditch runtime guard — and only if Lombok is already in your project. Never use Lombok as a replacement for JSpecify, and never use it in interfaces. Drawbacks of introducing Project Lombok to a new codebase I discussed in this post.
- Method returns: Prefer Java
Optionalonly for semantic absence: absence of a value is part of normal domain logic, e.g. fining by a criteria (filter) in a database. However, keep in mind thatOptionaldoes not come for free:- It’s non-serializable by default, hence does not fit returning types for controllers.
- It creates extra GC overhead as a wrapper object. For hot paths, a simple JSpecify’s
@Nullablereturn may be more efficient.
This post focused only on the internal Java API surface, but true null-safety in production systems spans far beyond method signatures. It relies on a multilayered defense: runtime validation at service and DTO boundaries (Hibernate Validator, custom validators), database-level guarantees via NOT NULL and CHECK constraints, and clear external API contracts that prevent clients from sending malformed data in the first place. When these layers reinforce each other, nulls are caught at their point of origin, ensuring they never leak into business logic or explode later as a NullPointerException.