JSpecify + Spring Boot 4: Finally Fixing Java’s Billion-Dollar Mistake
Tony Hoare introduced the null reference in 1965 and later called it his “billion-dollar mistake.” For Java developers, the pain has been felt in two distinct forms: the NullPointerException that crashes production at 3 AM, and the years of annotation-library fragmentation that made it nearly impossible to agree on a consistent way to prevent that crash in the first place.
JetBrains had @Nullable. Android had its own version. JSR-305 stalled without ever becoming a standard. Spring Framework shipped org.springframework.lang.@NonNullApi and @Nullable back in version 5 because there was simply no better option at the time. The result was a forest of incompatible annotations where a library annotated with one set could not communicate null contracts to a project using another.
JSpecify — a collaborative open specification backed by Google, JetBrains, Meta, Sonar, Oracle, and Broadcom — shipped its 1.0 release in July 2024. Then, in November 2025, Spring Framework 7 and Spring Boot 4 went GA with JSpecify as their null-safety standard throughout the entire portfolio. Spring’s own older annotations (org.springframework.lang.@Nullable, @NonNullApi, @NonNullFields) are now deprecated in favour of the JSpecify equivalents.
In this article we will look at what the four JSpecify annotations actually mean at the Spring framework boundary, how IDE enforcement and the NullAway build checker work together, and — crucially — the three patterns that still produce NullPointerExceptions even after you have annotated everything correctly.
What @Nullable, @NonNull, @NullMarked, and @NullUnmarked Actually Mean
Before we look at how Spring uses these annotations, it is worth being precise about what each one signals. JSpecify provides exactly four annotations, and the design is deliberately minimal:
The key insight here is the inversion of the default. In a @NullMarked package or class, you do not annotate every non-null thing — that would mean annotating the vast majority of your code. Instead, you declare the zone as non-null-by-default and annotate only the exceptions, the places where null is genuinely a valid value. This matches how well-written code already works conceptually, which is why the model feels natural once you start using it.
A critical technical difference from Spring’s old annotations
Spring’s deprecated annotations targeted ElementType.METHOD, ElementType.PARAMETER, and ElementType.FIELD. JSpecify’s annotations specify ElementType.TYPE_USE. That is a subtle but significant difference. TYPE_USE means annotations can appear on any usage of a type — including generic type arguments, array elements, and vararg elements. Consequently, you can now express contracts that were previously inexpressible:
Generic nullability — now expressible with JSpecify
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import java.util.List;
@NullMarked
public class OrderRepository {
// A non-null list whose elements may themselves be null
public List<@Nullable Order> findAllIncludingCancelled() { ... }
// A non-null list of guaranteed non-null orders
public List findActiveOrders() { ... }
// The list reference itself may be null (rare, but expressible)
public @Nullable List findByCustomer(String customerId) { ... }
}
With METHOD-level annotations, the middle case was indistinguishable from the last. With TYPE_USE semantics, the difference is explicit and tooling-verifiable.
Spring Framework 7’s null-safe portfolio now includes Spring Security, Spring Data, Spring Integration, Spring Batch, Spring AMQP, Spring for Apache Kafka, Spring Web Services, and more. Kotlin 2.2 automatically translates JSpecify annotations into Kotlin’s native nullability, so Kotlin users get full type-system enforcement for free.
What This Means at the Spring Framework Boundary
The immediate practical benefit arrives before you write a single annotation in your own code. Because the Spring codebase itself now carries JSpecify annotations, your IDE and static analyser can see the null contracts on every Spring API you call. This changes the nature of the feedback you get.
Reading @Nullable return values from Spring APIs
Consider Environment.getProperty(String). In Spring Boot 3 and earlier, the return type was String — no annotation, no signal. A developer fetching a property and immediately calling a method on the result had no tooling warning that the property might not exist.
In Spring Framework 7, the contract is expressed on the return type: the method returns @Nullable String. Inside a @NullMarked class, your IDE will now underline every place you dereference that return value without a null check, and NullAway will fail your build.
Before Spring Framework 7 — no signal from tooling
// Spring Boot 3: return type is plain String — no annotation, no warning
String timeout = environment.getProperty("app.timeout");
int value = Integer.parseInt(timeout); // NPE risk, compiler is silent
Spring Framework 7 — contract is visible, tooling enforces it
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.springframework.core.env.Environment;
@NullMarked
public class TimeoutConfig {
private final Environment environment;
public TimeoutConfig(Environment environment) {
this.environment = environment;
}
public int getTimeoutSeconds() {
@Nullable String raw = environment.getProperty("app.timeout");
// IDE and NullAway warn here if you skip the null check
if (raw == null) {
return 30; // sensible default
}
return Integer.parseInt(raw);
}
}
Passing @NonNull parameters to Spring callbacks
The reverse direction matters too. Spring’s BeanPostProcessor, HandlerInterceptor, and similar hook interfaces now declare @NonNull on the parameters they pass to your implementations. That means if your implementation ever tries to pass those values further into a method that accepts @Nullable, you get a contract-tightening warning — the kind of over-defensive null check that would have silently wasted runtime cycles in Spring Boot 3.
Migration pathYou do not need to annotate your entire codebase at once. Add @NullMarked to a package-info.java one package at a time. Any package without the annotation continues to behave exactly as Java always has — unspecified nullability. This gradual adoption model was a deliberate design goal of JSpecify.
Setting up NullAway in your build
IDE warnings are useful but ignorable. NullAway — an Error Prone plugin maintained by Uber — turns them into build failures. When a @Nullable return value is dereferenced without a null check inside a @NullMarked package, NullAway fails the compile step with an explicit message: [NullAway] dereferenced expression is @Nullable.
Gradle configuration for NullAway with JSpecify mode
// build.gradle
plugins {
id 'java'
}
dependencies {
annotationProcessor 'com.uber.nullaway:nullaway:0.12.3'
compileOnly 'org.jspecify:jspecify:1.1.0'
// Required for Error Prone
errorprone 'com.google.errorprone:error_prone_core:2.35.1'
}
tasks.withType(JavaCompile).configureEach {
options.errorprone {
check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR)
option("NullAway:AnnotatedPackages", "com.example.myapp")
// Enable full JSpecify mode (requires JDK 22+ toolchain)
// option("NullAway:JSpecifyMode", "true")
}
}
Standard NullAway enforcement works on JDK 17+. Full JSpecify mode — which also checks nullability on generics, arrays, and varargs — requires at least JDK 22 as the compilation toolchain (you can still target a Java 17 bytecode level using --release 17). Enable JSpecify mode as a second step, after ensuring your codebase is clean under the basic configuration first.
The null-annotation fragmentation era (2006 – 2026)
IDE Enforcement in Practice
Tooling support arrived in earnest alongside Spring Boot 4. IntelliJ IDEA 2025.3 now automatically prefers JSpecify annotations when they are on the classpath — even over JetBrains’ own annotations. Quick-fixes and refactorings generate them automatically. The Spring Tools team is also working on automatic JSpecify configuration for Eclipse and VS Code, building on Eclipse’s existing nullability annotation support.
The practical experience inside IntelliJ looks like this: when you call a Spring method whose return type is @Nullable and immediately pass that result into a @NonNull parameter, the IDE highlights the call site with a warning. Hover over it and you get an explanation and a one-click fix — either add a null check, provide a default, or use Optional. The feedback is immediate and contextual, without needing a full build cycle.
| Tool | JSpecify support level | Enforcement type | Setup effort |
|---|---|---|---|
| IntelliJ IDEA 2025.3+ | Full incl. generics + data flow | IDE warning / error | Zero — auto-detected |
| NullAway + Error Prone | Standard + opt-in JSpecify mode | Build failure | Moderate — Gradle/Maven config |
| Eclipse (Spring Tools 4) | Partial — in progress | IDE warning | Manual configuration |
| VS Code (Spring Tools) | Partial — in progress | IDE warning | Manual configuration |
| SonarQube / SonarCloud | Standard | Quality gate | Low — rule activation |
Three Patterns That Still Produce NPEs Anyway
This is the section that most articles skip, and it is the most important one. JSpecify is a static analysis standard, not a runtime guarantee. There are three structural patterns where null can still arrive at a dereference site even when your code is fully annotated and your build is clean.
Pattern 1: Unannotated third-party libraries crossing your boundary
@NullMarked applies to the scope you declare it in. It says nothing about the code that calls into yours from outside. When a third-party library that has not adopted JSpecify passes a null value to your @NonNull parameter, your annotation is a documentation contract, not a runtime guard. The JVM does not enforce it.
As the JCG JSpecify vs Kotlin article puts it precisely: annotations prevent you from writing null-unsafe code — they cannot stop null from arriving from outside your annotated perimeter.
Null slipping in from an unannotated caller
// Your annotated service — NullAway sees no problem here
@NullMarked
public class InvoiceService {
public String generateReference(@NonNull String customerId) {
// Tooling guarantees customerId is non-null... within the annotated world
return "INV-" + customerId.toUpperCase();
}
}
// Unannotated legacy module (no @NullMarked, no JSpecify dependency)
public class LegacyBillingAdapter {
public void process(InvoiceService svc, String id) {
// id might be null here; no tooling warning in this unannotated class
svc.generateReference(id); // NPE at runtime if id == null
}
}
For any public API surface that is genuinely called from unannotated code, combine JSpecify annotations with a runtime Objects.requireNonNull() guard or Bean Validation (@NotNull with a validator). Annotations handle the intra-annotated-world; guards handle the boundary.
Pattern 2: Reflection and framework-level injection bypassing the type system
Spring itself is the most common culprit here. Constructor injection is safe — Spring resolves the bean or fails fast at startup. But field injection via @Autowired operates through reflection and sets the field after the constructor runs. Your @NonNull annotated field is null during the constructor body if anything else calls the constructor directly — including unit test frameworks that instantiate your class without the Spring context.
Field injection bypasses @NonNull at construction time
@NullMarked
@Service
public class PaymentService {
@Autowired
private PaymentGateway gateway; // @NonNull by @NullMarked default
// In tests: new PaymentService() — gateway is null here.
// NullAway sees no problem because @Autowired promises injection.
public PaymentResult charge(String token, int amount) {
return gateway.process(token, amount); // NPE in unit tests without Mockito
}
}
// Prefer constructor injection — nullability is verifiable at construction
@NullMarked
@Service
public class PaymentService {
private final PaymentGateway gateway;
public PaymentService(PaymentGateway gateway) {
this.gateway = gateway; // null here means Spring failed at startup — safe
}
...
}
NullAway provides
@SuppressWarnings("NullAway.Init")specifically for fields that are legitimately initialised by a framework rather than a constructor (Hibernate entities with JPA, Spring’s@Value,@Autowiredfields in legacy code). Use it deliberately and document the reason — it should never become a blanket escape hatch.
Pattern 3: @NullMarked not propagating through sub-packages
This is arguably the most surprising gotcha. JSpecify does not assume hierarchy when applying @NullMarked through package-info.java. If you add @NullMarked to com.example.orders, it does not automatically cover com.example.orders.internal or com.example.orders.dto. Each sub-package needs its own package-info.java.
In practice this means your carefully annotated service layer can have a completely unannotated DTO sub-package sitting silently below it, and NullAway will not flag the inconsistency — it simply treats the DTO classes as having unspecified nullability, which is a silent gap in your safety net.
package-info.java — required per package, no auto-inheritance
// com/example/orders/package-info.java @NullMarked package com.example.orders; import org.jspecify.annotations.NullMarked; // com/example/orders/dto/package-info.java ← must exist separately @NullMarked package com.example.orders.dto; import org.jspecify.annotations.NullMarked; // com/example/orders/internal/package-info.java ← and this one too @NullMarked package com.example.orders.internal; import org.jspecify.annotations.NullMarked;
Some teams apply
@NullMarkedat the class level rather than the package level during an incremental migration, precisely because it avoids the sub-package inheritance confusion. The Spring team recommends adding a module-levelmodule-info.javawith@NullMarkedif you want whole-module coverage — but that requires adopting Java modules, which is a separate migration concern.
Where NPEs come from after full JSpecify adoption
Conclusion: A Genuine Step Forward, Not a Silver Bullet
Spring Framework 7 and Spring Boot 4’s adoption of JSpecify is the most consequential change to Java null-safety in a decade. The fragmentation era is genuinely over — one annotation standard, backed by the organisations that build the tools you actually use, now covers the Spring portfolio. That alone removes a significant category of “which annotation should I use?” friction from every Java project started from this point forward.
At the same time, it would be dishonest to call this the end of the NPE. JSpecify is declarative, not structural. Kotlin’s null safety is baked into the type system — it cannot be bypassed without explicitly opting out. JSpecify’s is enforced by tooling configuration and annotation coverage, both of which have gaps. The three patterns we covered — unannotated callers, reflection-based injection, and missing sub-package declarations — are exactly those gaps in practice.
The productive way to think about it is this: JSpecify moves null-safety from a runtime problem you discover in production to a design-time conversation you have during code review. That is enormously valuable. Just do not mistake the conversation for a guarantee.
What We Learned
We began by tracing the decade-long fragmentation of null-annotation libraries in the Java ecosystem and explained why JSpecify — now the official standard in Spring Framework 7 and Spring Boot 4 — represents a genuine consolidation rather than just another option. We then dissected all four JSpecify annotations (@Nullable, @NonNull, @NullMarked, @NullUnmarked), highlighting the critical shift from METHOD-level targeting to TYPE_USE semantics that finally makes generic-type nullability expressible. We saw how the Spring framework boundary now carries explicit null contracts, shifting IDE and NullAway feedback from your own code all the way to the framework calls you make.
We walked through practical build configuration for NullAway, including the JDK 22 requirement for full JSpecify mode. Finally, and most importantly, we examined three real patterns — unannotated third-party callers, reflection-based framework injection, and the non-inheritance of @NullMarked across sub-packages — that remain live NPE sources even in a fully annotated codebase, along with the defensive countermeasures for each.







