Skip to main content
Spring Boot 4 Null Safety: How JSpecify Moves NPEs Earlier to Compile Time
  1. Posts/

Spring Boot 4 Null Safety: How JSpecify Moves NPEs Earlier to Compile Time

·2794 words·14 mins
NeatGuyCoding
Author
NeatGuyCoding

Spring Boot 4 Null Safety: How JSpecify Moves NPEs Earlier to Compile Time
#

Java’s null reference remains one of the most common sources of runtime failures in production. Tony Hoare famously called it the “billion-dollar mistake.” The Spring team’s goal is not to make NullPointerException disappear from the JVM, but to intercept most null-reference problems at compile time or build time—before applications reach production—through static analysis and explicit contracts.

Spring Boot 4 and Spring Framework 7 did not invent another vendor-specific annotation set. Instead, they fully embrace JSpecify—a cross-tool nullness semantics standard advanced by Google, JetBrains, Broadcom (Spring), and others. For application developers, this yields three layers of benefit: first, after upgrading dependencies, IDEs and the Kotlin compiler can understand Spring API nullness with almost zero configuration; second, business code can progressively introduce @NullMarked by package, gradually narrowing unspecified regions; third, NullAway with Error Prone can elevate contract violations to build failures in CI, aligned with the gatekeeping strategy used in Spring’s own source repositories.

This article walks through each layer using a “why → mechanism/constraints → how → common pitfalls” structure. Where content has not been verified line-by-line against official specifications or Javadoc, boundaries are called out explicitly.


Why a Cross-Tool Null Safety Standard Is Needed
#

Why: NullPointerException often surfaces under boundary conditions that integration tests never cover. If annotations are recognized by only one IDE, library authors cannot guarantee “write once, analyze everywhere.” Historically, JSR-305 shipped multiple incompatible variants on Maven Central, amplifying the confusion.

Mechanism / constraints: JSpecify 1.0.0 publishes the Nullness Specification and Nullness User Guide, defining concepts such as augmented type and null-marked scope. The Tool Conformance document explains how tools should interpret annotations consistently, but it does not mandate that tools emit specific diagnostics in specific scenarios—unified semantics and diagnostic strictness can be separated.

Core annotations include @NullMarked, @Nullable, and @NonNull. Maven coordinates:

<dependency>
  <groupId>org.jspecify</groupId>
  <artifactId>jspecify</artifactId>
  <version>1.0.0</version>
</dependency>

How: Start with the User Guide; the Specification 1.0.0 targets tool implementers, and ordinary application developers usually do not need to read it cover to cover.

Common pitfalls: Treating JSpecify as “just another @Nullable"—its value lies in scope (package-level default non-null) and complete semantics for generic parametric nullness, not in any single annotation. Another pitfall is assuming the specification will throw errors at runtime for you—JSpecify itself is static semantics only; what actually enforces contracts in CI are tools like NullAway and, longer term, Valhalla’s null-restricted types.

Nullness Specification version 1.0.0 — This document specifies the semantics of the Specify nullness annotations.


Spring Framework 7 Deprecates org.springframework.lang
#

Why: Annotations from org.springframework.lang introduced in the Spring 5 era (@NonNull, @NonNullApi, and others) cannot align with Kotlin 2.1+, NullAway, and the JSpecify toolchain. The Spring Framework 7.0 Release Notes explicitly mark these annotations deprecated in favor of JSpecify.

Mechanism / constraints: NonNull has been @Deprecated(since="7.0") since 7.0; the migration target is org.jspecify.annotations.NonNull. JSpecify annotations are TYPE_USE; placement on fields and return values may differ from the old annotations—consult the migration guide.

How:

// Before migration (deprecated)
import org.springframework.lang.NonNull;
void save(@NonNull String id) { }

// After migration
import org.jspecify.annotations.NonNull;
void save(@NonNull String id) { }

Mechanical migration can use OpenRewrite or Tanzu Application Advisor (recommended direction from the speaker; specific recipe names were not verified in public documentation for this talk).

Common pitfalls: Manually replacing imports globally while ignoring type-use placement differences, leaving annotations on declarations instead of use sites where static analyzers cannot read them.

Should you use JSpecify annotations in your Java code? — What about unannotated types?


Package-Level @NullMarked and Spring Ecosystem Default Non-Null
#

Why: Traditional Java APIs default to nullable; callers must guess from Javadoc or source. The Spring team wants a unified strategy across Framework, Boot, Security, Data, Reactor, Micrometer, and related projects: packages default to non-null, with @Nullable only where null is genuinely possible.

Mechanism / constraints: Declaring @NullMarked on package-info.java makes unannotated type usages in that package @NonNull. The JSpecify User Guide stresses that packages are not hierarchical—marking com.foo does not automatically cover com.foo.bar; subpackages must each maintain their own package-info.java. Spring projects integrate NullAway in CI to prevent annotation drift from implementation (per-repository configuration was not verified repo by repo).

The claim that “almost every package carries @NullMarked, with only about 5% of types explicitly @Nullable” comes from the official Spring blog post and talk narrative; no formal statistics script was found—treat it as engineering experience, not audited data.

How:

// src/main/java/com/example/app/package-info.java
@NullMarked
package com.example.app;

import org.jspecify.annotations.NullMarked;

Common pitfalls: Assuming business code becomes null-safe automatically after consuming Boot 4—Spring library annotations cover library APIs only; application packages still need their own @NullMarked (see below). Also, annotation coverage in some projects such as Spring Cloud remains incomplete (partial, per ecosystem checklist slides); when calling across modules, confirm separately whether the target module is @NullMarked.

Spring Boot 4 with JSpecify — Package default to non-null, generic type nullability, and related capability checklist

Package are null-marked — @NullMarked annotation at the top of org.springframework.boot package Javadoc


Kotlin 2.1+ and Automatic JSpecify Mapping
#

Why: In the Boot 3 era, when Kotlin called Spring Java APIs, unannotated types degraded to platform types (e.g., String!, Set<String!>!); the compiler did not block passing null to non-null parameters, and NPE risk remained at runtime.

Mechanism / constraints: The Spring Framework Reference confirms that JSpecify annotations are automatically translated into Kotlin null safety. Since Kotlin 2.1.0, JSpecify nullness mismatches default to strict (compile error); JSpecify is the only Java annotation style with a default strict report level (Java interop documentation). @NullableT?; everything else inside @NullMarked packages → non-null T.

How:

@SpringBootApplication
open class Boot4KotlinApplication

fun main(args: Array<String>) {
    val app = SpringApplication(Boot4KotlinApplication::class.java)
    val profiles: Set<String> = app.additionalProfiles   // elements non-null
    app.setBeanNameGenerator(null)  // compile error: parameter is non-null
}

The same scenario in Boot 3 still shows Set<String!>! in the IDE; Boot 4 maps it to Set<String>:

Spring API from Kotlin in Spring Boot 3 — kotlin.collections.(Mutable) Set<String!>!

Spring API from Kotlin in Spring Boot 4 — kotlin.collections.(Mutable) Set<String>

Common pitfalls: Projects that used -Xnullability-annotations=@org.jspecify.annotations:warning to suppress warnings should note that Kotlin 2.1 changed the default to strict—confirm behavior in the Compatibility Guide.

Nullability mismatches trigger compiler errors — Null cannot be a value of a non-null type BeanNameGenerator


IDE Static Analysis: Immediate Gains with Zero Migration Cost
#

Why: The lowest-cost path to moving NPEs out of production is to upgrade Spring dependencies only—IDEs that support JSpecify can warn on Spring API nullness immediately.

Mechanism / constraints: IntelliJ IDEA includes org.jspecify.annotations.Nullable / NonNull in its default nullability list. Spring documentation also recommends IntelliJ and Eclipse (the latter requires manual configuration). Native JSpecify support in the VS Code Java extension was not found in first-party public documentation—marked here as speaker opinion.

On Optional: the JDK Optional @apiNote states it is primarily for method return types; it should not serve as a general null carrier—consistent with Spring API design on that point.

How: After upgrading to Boot 4, dereferencing nullable return values, passing null to non-null parameters, and similar cases trigger IDE inspection “Nullability and data flow problems” directly.

Common pitfalls: No IDE warnings ≠ runtime safety—unannotated business code remains in unspecified state; IDE checks are supplementary and cannot replace build-time gates. Subprojects such as Spring AI 2.0 refactored APIs with option types, modeling “value may be absent” explicitly instead of implicit null (speaker case study; Spring AI 2.0 source changes were not independently verified)—design-level benefits complement annotation checking but are outside this article’s static-analysis scope.

Check parameter nullability in the IDE — Passing null argument to parameter annotated as @NotNull


Progressive Annotation of Business Code: Unspecified vs. Null-Marked Dual Model
#

Why: However complete Spring libraries are, application business code remains the main source of NPEs. Large monoliths cannot be fully annotated overnight; scopes must expand progressively.

Mechanism / constraints: The JSpecify User Guide defines a three-state model:

StateMeaning
unspecifiedUnannotated; tools “do not know if null is allowed”; on the Kotlin side, similar to platform types
Unannotated usage inside @NullMarkedTreated as @NonNull
Explicit @NullableNullable

Libraries should expose org.jspecify:jspecify as a transitive dependency (not compile-only) so downstream tools can read nullness (speaker Q&A view, aligned with Spring guide direction).

How:

// com/example/orders/package-info.java
@NullMarked
package com.example.orders;

import org.jspecify.annotations.NullMarked;

public class OrderService {
    public @Nullable Order findById(String id) { return null; }
    public void save(Order order) { }  // order: non-null
}

Common pitfalls: Adding @NullMarked on a parent package does not inherit to child packages—it does not; each subpackage needs its own package-info.java.

#1 Read JSpecify documentation — Java variables are references — What about unannotated types?


Advanced Expression: @Contract, Generics, and Array Type-Use
#

Why: @Nullable / @NonNull alone cannot express control-flow facts such as “variable is non-null after Assert.notNull” or “return nullness depends on parameter nullness,” leading to many false positives from NullAway.

Mechanism / constraints:

  • Spring retains org.springframework.lang.Contract (not deprecated with the null-safety annotations), with semantics inspired by JetBrains @Contract; Assert.notNull is annotated @Contract("null, _ -> fail") (source verifiable).
  • NullAway must be configured with NullAway:CustomContractAnnotations=org.springframework.lang.Contract to recognize Spring contracts (Wiki).
  • Generic bounds: TransactionCallback<T extends @Nullable Object> combined with @Nullable return types determines parametric nullness (spring-tx source).
  • Arrays and nested types must follow JLS type-use placement: @Nullable Object[] (elements nullable) vs. Object @Nullable[] (array reference nullable) vs. @Nullable Object @Nullable[] (both nullable).

Inclusion of @Contract in JSpecify standardization is still under working-group discussion (speaker view; JSpecify 1.0 annotation set does not include @Contract). The JSpecify specification uses the term parametric nullness, not the colloquial “polynull.”

How:

import org.springframework.util.Assert;

public void process(String foo) {
    Assert.notNull(foo, "foo required");
    foo.length(); // NullAway: foo known non-null after Assert
}

Common pitfalls: Confusing Cache.@Nullable ValueWrapper with @Nullable Cache.ValueWrapper—the compiler resolves different augmented types.

Mermaid diagram 1

Most frequent contracts in Spring Framework codebase — null, _ -> fail and other JetBrains-style contract statistics


NullAway + Error Prone: Build-Time Enforcement
#

Why: IDE checks depend on each developer’s local environment and cannot guarantee long-term consistency across the team and Kotlin consumers; Spring projects already use NullAway in CI to elevate nullness violations to errors.

Mechanism / constraints:

  • NullAway runs as an Error Prone plugin; requires JDK 17+ and Error Prone 2.36.0+.
  • NullAway:OnlyNullMarked=true: checks only @NullMarked scopes, supporting progressive adoption (Wiki).
  • NullAway:JSpecifyMode=true: enables fuller JSpecify semantics (generics, arrays, etc.); still evolving, may false-positive (JSpecify Support Wiki). Note the official option name is JSpecifyMode, not AcknowledgeJSpecifyMode as in some talk materials.
  • Type annotation bytecode: NullAway 0.12.11+ requires javac on JDK 22+, or on OpenJDK 17.0.19+ / 21.0.8+ add -XDaddTypeAnnotationsToSymbol=true (Oracle JDK 17/21 does not support that flag).

How (Gradle essentials; version numbers per NullAway README):

tasks.withType<JavaCompile>().configureEach {
    options.errorprone {
        disableAllWarnings.set(true)
        check("NullAway", CheckSeverity.ERROR)
        option("NullAway:JSpecifyMode", "true")
        option("NullAway:OnlyNullMarked", "true")
        option("NullAway:CustomContractAnnotations", "org.springframework.lang.Contract")
    }
}

Spring documentation recommends phased enablement: start with OnlyNullMarked + CustomContractAnnotations, then turn on JSpecifyMode. A reproducible sample is in the speaker’s jspecify-nullaway-demo repository.

On Maven, configure maven-compiler-plugin with fork=true, annotationProcessorPaths (error_prone_core + nullaway), and compilerArgs passing NullAway options and -XDaddTypeAnnotationsToSymbol (OpenJDK 17.0.19+ / 21.0.8+). The demo repository’s maven branch provides a full pom.xml reference. Target bytecode level can use --release 17 separately from a newer javac—compiler version and runtime JDK need not match.

Optionally, Error Prone’s RequireExplicitNullMarking checker requires classes or packages to declare @NullMarked or @NullUnmarked explicitly—stricter than NullAway alone; whether to enable it depends on team tolerance for “undecided regions.”

Common pitfalls: Compiling with an older javac while enabling JSpecifyMode—type annotations cannot be written to class files, and NullAway cannot read use-site annotations. Another pitfall is treating an aggressive “enable JSpecifyMode in one step” from talks/demos as Spring’s only official recommendation—documentation actually suggests phased rollout.

Mermaid diagram 2

Maven pom.xml — java.version 25, org.jspecify jspecify 1.0.0, maven-compiler-plugin 3.14.1


Agent-Assisted Annotation: Encoding Experience as Iterable Rules
#

Why: Hand-writing JSpecify annotations at scale is costly; rules are fine-grained (generics, arrays, Contract, inferring @Nullable), and a generic LLM prompt like "add JSpecify to my project" often fails.

Mechanism / constraints: Spring Security team practice (speaker sharing, not official specification): pre-create Agent Skills—jspecify-user-guide (from User Guide URL), jspecify-spring-null-safety, jspecify-spring-framework-patterns (analyze spring-core source patterns), nullaway-configure (from demo repository)—then run an “annotate → build → read NullAway errors → fix → capture rules in jspecify-agentic-loop-analysis” loop. Model effectiveness varies by load and version; non-deterministic.

How: Solidify the JSpecify User Guide, Spring null-safety documentation, and NullAway configuration each as a skill; advance package by package in small steps; each build failure log feeds the next fix round. Workflow skeleton roughly: (1) create jspecify-user-guide skill from User Guide URL; (2) create jspecify-spring-null-safety from Spring null-safety docs; (3) create jspecify-spring-framework-patterns by analyzing spring-core on GitHub; (4) create nullaway-configure from demo repository; (5) configure NullAway; (6) annotate by package and loop builds until clean; (7) append new rules to jspecify-agentic-loop-analysis. The speaker reported good results with Claude Sonnet/Opus and failed attempts with Gemini—model choice is personal experience, not an official conclusion.

Common pitfalls: Treating agent output as merge-ready final state—it still needs human review and CI gates; skill names and prompts have no fixed official Spring/JSpecify package. Do not skip NullAway configuration and let an agent bulk-add annotations—without a build feedback loop, annotation quality cannot be verified.


Runtime Reflection: org.springframework.core.Nullness
#

Why: Framework features (such as whether @RequestParam is mandatory) need to query parameter nullness at runtime, understanding JSpecify, Kotlin reflection, and per-package @Nullable together.

Mechanism / constraints: The Nullness enum (since 7.0) takes values UNSPECIFIED / NULLABLE / NON_NULL; factory methods include forMethodReturnType, forParameter, forMethodParameter, forField, and others. Fully supported: JSpecify, Kotlin null safety, @Nullable on any package (no package-name check). Not supported: JSR-305; org.springframework.lang @NonNullApi / @NonNullFields / @NonNull (@Nullable still supported via package-agnostic check).

How:

import org.springframework.core.Nullness;
import java.lang.reflect.Method;

Method m = MyController.class.getMethod("handle", String.class);
Nullness p0 = Nullness.forParameter(m.getParameters()[0]);
// When UNSPECIFIED, @RequestParam defaults to mandatory (speaker example; also depends on required and other attributes)

Common pitfalls: Assuming the Nullness API resolves @Contract—it handles nullness only, not control-flow contracts.

Enum Class Nullness — UNSPECIFIED NULLABLE NON_NULL — JSpecify annotations are fully supported


Project Valhalla and Null-Restricted Types: A Look Ahead
#

Why: Pure annotation approaches express semantics but do not change memory layout; null-restricted types on the Project Valhalla roadmap aim to fold null checks into compile time and runtime and support value-class alignment optimizations.

Mechanism / constraints: JEP 8316779 (Draft) defines preview syntax: a type name suffix ! denotes non-null restricted, e.g., Predicate<? super E>!. Preview requires --enable-preview. JSpecify remains the pragmatic choice today; migration from JSpecify annotation processors to bytecode-level null features, lazy constants, and similar items are roadmap directions with unverified timelines.

How (preview syntax illustration, not a GA feature):

// Preview: non-null parameter type
public boolean removeIf(Predicate<? super E>! filter) {
    return bulkRemove(filter);
}

Common pitfalls: Relying on preview syntax in production code—JEP is still Draft; syntax and JDK baselines may change. Do not defer JSpecify because of Valhalla’s long-term plans—Spring 7 documentation and the OpenJDK roadmap both treat JSpecify as the current pragmatic layer; Valhalla’s generic nullness capabilities are expected to remain less complete than JSpecify for some time (speaker view).

ArrayBlockingQueue — public boolean removeIf(Predicate<? super E>! filter)


Adoption Path at a Glance
#

If you are planning a Boot 3 → Boot 4 upgrade, you can advance layer by layer along dependency relationships:

  1. Upgrade for immediate gains: Bump Spring Boot 4 / Framework 7, open existing code in IntelliJ, and observe nullness hints on Spring APIs; Kotlin projects should confirm compiler ≥ 2.1.
  2. Clean up your own Spring annotations: Migrate org.springframework.lang.* to JSpecify (OpenRewrite / Advisor, or mechanical replace + manual type-use verification).
  3. Pick a first @NullMarked package: Start with core business modules or NPE-prone modules, add package-info.java, and add @Nullable based on NullAway/IDE errors.
  4. Wire CI gates: Error Prone + NullAway, start with OnlyNullMarked=true, then enable JSpecifyMode and CustomContractAnnotations as needed.
  5. Runtime needs: Framework extensions or custom parameter resolution can query Nullness.forParameter, but prioritize completing JSpecify migration—old Spring @NonNullApi is not supported by that API.

Null safety is not a one-time project but a shift from implicit “does this value exist?” conventions to machine-checkable contracts. Spring Boot 4 prepares ecosystem library contracts; application-side work is just beginning.


References and Further Reading
#

Related