Spring Boot 4.0 Null Safety: JSpecify Ends a Decade of Fragmentation?
Spring Boot 4.0 ships on top of Spring Framework 7.0, and one of the most consequential changes is how null safety is expressed across the Spring API surface.
Spring Framework 7.0 adopts JSpecify as its null annotation standard, moving away from its own org.springframework.lang annotations. The goal is to enable IDEs and static analysis tools to catch NullPointerException bugs at compile time instead of in production.
The Problem: Ten Years of Null Annotation Chaos
Java has no built-in null safety at the type-system level (unlike Kotlin or recent Swift). To compensate, the ecosystem created a forest of incompatible annotation libraries, each with slightly different semantics.
The JetBrains annotation was a nightmare in our projects, it tried to sneak in the code regularly.
| Library | Annotations |
|---|---|
| JSR-305 / FindBugs | javax.annotation.Nullable, @NonNull |
| JetBrains | org.jetbrains.annotations.Nullable |
| Eclipse | org.eclipse.jdt.annotation.Nullable |
| Checker Framework | org.checkerframework.checker.nullness.qual.Nullable |
| Android | android.annotation.Nullable |
| Lombok | lombok.NonNull |
| Spring Framework (pre-7.0) | org.springframework.lang.Nullable, @NonNull, @NonNullApi, @NonNullFields |
The result: a library annotated with JetBrains annotations couldn't communicate null contracts to a project using JSR-305. Tooling support was inconsistent. No annotation became authoritative enough for every major framework to adopt.
JSpecify: The Standardization Effort
JSpecify is an industry collaboration to define a single, semantically precise null annotation 'standard' for the entire Java ecosystem. The project is backed by engineers from Google, JetBrains, Oracle, and others.
JSpecify 1.0 shipped in October 2024. Its core annotations are:
@Nullable(org.jspecify.annotations.Nullable) — this type may benull@NullMarked— marks a scope (package, class, method) as null-safe by default: all types in that scope are treated as non-null unless explicitly marked@Nullable. Think about Kotlin.@NullUnmarked— opts a nested scope back out of null marking (useful when migrating legacy code)
The key design decision: rather than annotating every non-null type (which is verbose and noisy), you declare a scope as @NullMarked and only annotate the exceptions with @Nullable. This is identical to how Kotlin works.
What Spring Framework 7.0 Changed
Spring Framework 7.0 adopts JSpecify as its null annotation standard. The key changes are:
- Spring APIs are progressively annotated with
@NullMarkedat the package level and@Nullableon parameters and return types that can be null - The old
org.springframework.langnull annotations (@NonNull,@Nullable,@NonNullApi,@NonNullFields) are superseded by their JSpecify equivalents
The practical result: where Spring APIs carry these annotations, your IDE or a static analysis tool like NullAway can warn you when you forget to handle a potentially null value. The same applies to your own code once you annotate it with @NullMarked.
Spring recommended NullAway since the introduction of JSpecify: https://spring.io/blog/2025/03/10/null-safety-in-spring-apps-with-jspecify-and-null-away
Enabling Compile-Time Checks
Add NullAway to your pom.xml to enforce JSpecify contracts at build time:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>-Xplugin:ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.example</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.28.0</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>0.13.1</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
IntelliJ IDEA detects @NullMarked / @Nullable natively with no additional configuration.
IDE Support
JSpecify support varies across IDEs. Here is the current state.
IntelliJ IDEA — the most complete JSpecify integration among major IDEs. Recent versions understand @NullMarked scopes and @Nullable natively without plugins. Null dereference warnings and flow analysis (the IDE knows that after if (x == null) return; the variable is safe) work out of the box. Check your specific version's release notes for the exact level of support.
Eclipse IDE — support is provided through the JDT null analysis engine, which has understood annotation-based null contracts for years. You need to configure the project to treat org.jspecify.annotations.Nullable as the "nullable" annotation and @NullMarked as the "non-null by default" annotation under Project Properties → Java Compiler → Errors/Warnings → Null analysis. Once configured, Eclipse's flow analysis is solid.
Visual Studio Code — null safety analysis depends on the Java language server in use. With the Extension Pack for Java (which bundles the Eclipse JDT language server), you get the same null analysis as Eclipse after configuring the annotation mappings. Alternatively, running NullAway as part of your Maven build surfaces errors directly in the Problems panel. Real-time highlighting is less refined than IntelliJ's but build-time enforcement is identical.
NetBeans — native JSpecify support (@NullMarked scope semantics) is limited. SpotBugs or NullAway integrated into your Maven build will catch violations at compile time, which is the practical path for NetBeans users.
Regardless of IDE, wiring NullAway into your Maven or Gradle build gives you a consistent, IDE-independent safety net that runs in CI — which is ultimately where null contracts need to be enforced.
Example 1: RestTemplate Response — Silent NPE Becomes a Build Error
RestTemplate.getForObject() returns @Nullable in Spring 7. Before, this was documented in Javadoc but nothing stopped you from forgetting to check.
Before (Spring Boot 3.x)
@Service
public class WeatherClient {
private final RestTemplate restTemplate;
public String fetchTemperature(String city) {
// Spring 6: getForObject is @Nullable in Javadoc but callers rarely notice
String result = restTemplate.getForObject(
"https://api.weather.example.com/temp?city=" + city,
String.class
);
// NPE in production when API returns nothing — compiler never warned us
return result.trim();
}
}
After (Spring Boot 4.0 with JSpecify)
// package-info.java
@NullMarked
package com.example.client;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@Service
public class WeatherClient {
private final RestTemplate restTemplate;
public String fetchTemperature(String city) {
@Nullable String result = restTemplate.getForObject(
"https://api.weather.example.com/temp?city=" + city,
String.class
);
// IntelliJ/NullAway: ⚠ "result" may be null — dereference not safe
// Compiler forces you to handle it:
if (result == null) {
return "unavailable";
}
return result.trim(); // safe — null has been ruled out
}
}
This one annotation — on your package, not Spring's — is enough for tooling to catch the dereference. If Spring's own API also carries @Nullable on that return type, the warning fires even without the local variable annotation.
Example 2: Repository Contract — Self-Documenting Null Behavior
find* methods that return a single entity often return null when nothing is found. Before JSpecify, callers had to read the Javadoc (or learn from production).
Before (Spring Boot 3.x)
public interface OrderRepository extends JpaRepository<Order, Long> {
// Returns null if not found — but callers have no compile-time signal
Order findByOrderNumber(String orderNumber);
}
@Service
public class InvoiceService {
public BigDecimal calculateTax(String orderNumber) {
Order order = orderRepository.findByOrderNumber(orderNumber);
// Forgot to check — NPE when order doesn't exist
return order.getTotal().multiply(TAX_RATE);
}
}
After (Spring Boot 4.0 with JSpecify)
@NullMarked
package com.example.repository;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
public interface OrderRepository extends JpaRepository<Order, Long> {
@Nullable Order findByOrderNumber(String orderNumber);
// No @Nullable: always returns a list (empty if none found)
List<Order> findByCustomerId(Long customerId);
}
@NullMarked
@Service
public class InvoiceService {
public BigDecimal calculateTax(String orderNumber) {
@Nullable Order order = orderRepository.findByOrderNumber(orderNumber);
// Build error without this check — NullAway refuses to compile
if (order == null) {
throw new OrderNotFoundException(orderNumber);
}
return order.getTotal().multiply(TAX_RATE); // compiler knows: safe
}
}
The contract is now encoded in the type, not buried in Javadoc. New team members reading the interface instantly understand the semantics.
Example 3: Spring MVC — Optional Request Parameters
@RequestParam(required = false) parameters are naturally nullable when absent. Before, there was nothing to remind you of that in the method signature.
Before (Spring Boot 3.x)
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping("/search")
public List<Product> search(
@RequestParam(required = false) String query,
@RequestParam(required = false) String category) {
// Both can be null — nothing warns you
// Classic mistake: calling .toLowerCase() without null check
String normalizedQuery = query.toLowerCase(); // NPE if user omits ?query=
return productService.search(normalizedQuery, category);
}
}
After (Spring Boot 4.0 with JSpecify)
@NullMarked
package com.example.web;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping("/search")
public List<Product> search(
@RequestParam(required = false) @Nullable String query,
@RequestParam(required = false) @Nullable String category) {
// IntelliJ underlines query immediately: ⚠ may be null
String normalizedQuery = query != null ? query.toLowerCase() : "";
return productService.search(normalizedQuery, category);
}
}
Adding @Nullable explicitly to the parameter makes the null contract visible in the method signature — both to your tooling and to anyone reading the code. Even without Spring's own annotations on the binding layer, the pattern enforces the check in your code.
Example 4: Optional @Value Property
@Value with a #{null} default injects null when a property is absent from the configuration. This is a common pattern for optional feature flags or external API keys, and a frequent source of NPEs. Note that @Value is generally discouraged in favour of @ConfigurationProperties, which gives you type safety, validation, and IDE autocompletion — but @Value remains widespread in real codebases and worth covering here.
Before (Spring Boot 3.x)
@Service
public class FeatureFlagService {
@Value("${feature.new-checkout:#{null}}")
private String newCheckoutFlag; // null when property is not set
public boolean isNewCheckoutEnabled() {
// NPE when the property is absent from application.properties
return newCheckoutFlag.equalsIgnoreCase("true");
}
}
After (Spring Boot 4.0 with JSpecify)
@NullMarked
@Service
public class FeatureFlagService {
@Nullable
@Value("${feature.new-checkout:#{null}}")
private String newCheckoutFlag;
public boolean isNewCheckoutEnabled() {
// Tooling warns: newCheckoutFlag is @Nullable — must check before use
return newCheckoutFlag != null && newCheckoutFlag.equalsIgnoreCase("true");
}
}
The @Nullable annotation on the field makes the optionality explicit in the class itself, rather than hiding it inside the @Value expression. Anyone reading the field declaration immediately understands it can be absent.
Migration from Spring Boot 3.x
If you're upgrading from Spring Boot 3.x, start with your most critical packages (like APIs or services) and use @NullUnmarked to silence warnings in legacy modules.
I found it helpful to tackle one module per sprint, using NullAway’s errors as a to-do list.
- Replace Spring's own annotations —
org.springframework.lang.Nullable→org.jspecify.annotations.Nullable. Check the Spring Framework 7.0 migration guide for the exact deprecation and removal policy. - Add
@NullMarkedto your packages — createpackage-info.javain each package. Start with the most critical ones (services, controllers). - Let the warnings guide you — don't try to fix everything at once. Enable IDE inspection or NullAway on a module-by-module basis.
- Use
@NullUnmarkedas an escape hatch — for legacy code you can't touch immediately, annotate the class or method with@NullUnmarkedto opt it out.
Risks and Potential Issues
JSpecify and its adoption by Spring are genuinely positive steps, but there are real friction points to be aware of before committing.
Annotation noise on large codebases. Adding @NullMarked to a package-level package-info.java immediately makes the compiler/tooling strict about every type in that package. On a mature codebase with hundreds of classes this can surface thousands of warnings at once. The recommended approach — migrating package by package, using @NullUnmarked as a temporary escape hatch — works, but it requires discipline to avoid turning @NullUnmarked into a permanent workaround that defeats the purpose.
Incomplete third-party library support. JSpecify only helps where both sides of a call are annotated. If you call a library that has not adopted JSpecify (which is still the majority of the ecosystem), the return types and parameters of that library appear as unannotated, non-null by default inside a @NullMarked scope — which is the wrong assumption. NullAway can be configured to treat unannotated code conservatively, but this is opt-in and adds configuration complexity.
@Nullable on local variables is not universally supported. JSpecify's @Nullable is a type-use annotation and can appear on local variables (@Nullable String result = ...). However, not all tools handle this consistently — some static analysers ignore local variable annotations, and some IDE versions treat them as redundant when flow analysis already tracks nullability. This leads to an inconsistent experience depending on your toolchain version.
False positives from generics. Null safety and Java generics interact in non-obvious ways. A List<@Nullable String> is different from a @Nullable List<String>, and tools can produce false positives or miss real issues when type parameters are involved. JSpecify's specification addresses this precisely, but tool implementations (including NullAway) are still catching up to the full spec in edge cases involving wildcards and type bounds.
Friction with Lombok. If your project uses Lombok's @Builder, @Data, or @RequiredArgsConstructor, generated code may not carry null annotations through correctly. You may encounter cases where a generated constructor accepts a @Nullable argument where the field is non-null, or vice versa — resulting in warnings you cannot silence without suppression annotations. Check the Lombok changelog for the version that introduced JSpecify-aware improvements.
It is still not a language feature. Annotations are metadata. A sufficiently motivated developer can call .get() on a nullable value and no amount of annotation infrastructure will stop the bytecode from running. JSpecify raises the cost of writing unsafe null handling — it doesn't make it impossible. Teams relying on this for security-critical null checks should also maintain runtime validation at their system boundaries.
Why This Matters Beyond Spring
JSpecify's adoption by Spring Framework — one of the most widely used Java frameworks in the world — is a meaningful signal. When a framework this large commits to a null annotation standard, tool vendors (IDEs, linters, bytecode analysers) have a strong incentive to implement it properly.
For Java developers, this is the closest the language ecosystem has come to having a unified answer to null safety without requiring a language change. It won't replace Optional for API design, and it won't give you Kotlin-style non-nullable types enforced by the compiler. But it will, finally, let a correctly annotated codebase surface null handling issues before deployment — which is exactly where those bugs should be caught.
Summary
| Spring Boot 3.x | Spring Boot 4.0 | |
|---|---|---|
| Null annotation library | org.springframework.lang (Spring-specific) |
org.jspecify.annotations (standard) |
| Framework API annotated? | Partially | Progressively (JSpecify adopted as standard) |
| Tooling signal | IDE hints, inconsistent | Compile errors with NullAway / full IDE support |
| Opt-in scope | Per-annotation | Per-package/class via @NullMarked |
| Migration path | N/A | Old annotations deprecated, bridged |
The investment is low — a package-info.java per package and a NullAway Maven plugin — and the return is a category of production bugs eliminated before deployment.
References
You can find more information about JSpecify at https://jspecify.dev.
Here the Spring Blog post, you can find which libraries are using JSpecify: https://spring.io/blog/2025/11/12/null-safe-applications-with-spring-boot-4.