How JSpecify and NullAway Bring Compile‑Time Null Safety to Spring Boot 4
Spring Boot 4 now embraces JSpecify’s null‑safety annotations, allowing developers to declare nullable and non‑null types at compile time; combined with the NullAway static analyzer, this shift eliminates mysterious NullPointerExceptions in production by making null contracts explicit, reducing defensive code, and improving maintainability.
NullPointerException has long haunted Java developers; code works locally but throws NPE in production.
Root cause: Java's type system cannot distinguish nullable from non‑null at compile time. When you see a method signature like User findUser(String id), you cannot know if it may return null; developers rely on docs or runtime testing.
JSpecify: Compile‑time null checking
JSpecify is a modern Java null‑safety annotation spec that makes the type system carry nullability information and validates it at compile time.
Spring Boot 4 adopts JSpecify, replacing the old JSR‑305 annotations, moving null‑safety checks from runtime to compile time. Using @NullMarked and @Nullable together with static analysis tools like NullAway enables:
Compile‑time detection of potential NPEs : no more surprises at runtime.
Explicit null contracts : method signatures clearly state which values may be null.
Reduced defensive code : no need for excessive null checks.
Improved maintainability : team members understand API null semantics without reading implementation.
Spring Boot 4 new feature: default non‑null
Spring Boot 4 introduces the concept of “non‑null by default”. Instead of assuming every object may be null, you annotate the rare nullable cases.
Example comparison:
// Before Spring Boot 4 – return nullability unknown
@Service
public class PigUserService {
public PigUser findUserByUsername(String username) {
return pigUserRepository.findByUsername(username); // may return null
}
}
// Spring Boot 4 with JSpecify – explicit nullability
@Service
@NullMarked // default non‑null
public class PigUserService {
@Nullable
public PigUser findUserByUsername(String username) {
return pigUserRepository.findByUsername(username); // explicitly may return null
}
}Applying @NullMarked at package or class level sets the new default rule: everything is non‑null unless annotated with @Nullable.
Practical case
In the Pig Mall order service, username is guaranteed non‑null, while couponCode is optional:
@NullMarked
package com.pig4cloud.pigx.admin.service;
@Service
public class PigOrderService {
public PigOrder createOrder(String username, @Nullable String couponCode) {
// username is non‑null – no check needed
sendConfirmation(username);
// couponCode may be null – must check
if (couponCode != null) {
applyCoupon(couponCode);
}
return new PigOrder(username, couponCode);
}
}The annotations make the method contract explicit: username cannot be null, couponCode may be null.
Null‑safe handling of collection types
JSpecify can also express nullability of collection elements. Example:
@Service
public class PigReviewService {
public List<@Nullable String> getProductReviews() {
List<@Nullable String> reviews = new ArrayList<>();
reviews.add("Great quality"); // filled
reviews.add(null); // empty review
reviews.add("Excellent service");
return reviews;
}
public int calculateReviewRate(List<@Nullable String> reviews) {
long completed = reviews.stream()
.filter(Objects::nonNull)
.count();
return (int) ((completed * 100) / reviews.size());
}
}The type List<@Nullable String> conveys that the list itself is never null, but its elements may be.
Configuring null‑safety in a project
Step 1: Set package‑level default
@NullMarked
package com.pig4cloud.pigx.admin.service;
import org.jspecify.annotations.NullMarked;Note: @NullMarked applies only to the package where the file resides; you need a package‑info.java with the annotation in each package you want to default to non‑null.
Step 2: Annotate nullable return values
@NullMarked
@Service
public class PigGoodsService {
@Nullable
public PigGoods findById(Long id) {
return goodsRepository.findById(id).orElse(null);
}
// Better practice: return Optional
public Optional<PigGoods> findGoodsById(Long id) {
return goodsRepository.findById(id);
}
}Step 3: Annotate nullable parameters
@RestController
@NullMarked
public class PigGoodsController {
@PostMapping("/goods")
public PigGoods createGoods(@RequestBody PigGoods goods,
@RequestHeader("X-User-Id") @Nullable String userId) {
// goods is non‑null
validateGoods(goods);
// userId may be null
if (userId != null) {
auditLog(userId, "Create goods: " + goods.getName());
}
return pigGoodsService.save(goods);
}
}Compile‑time safety check: NullAway integration
Integrating NullAway makes the compiler fail when a possible null dereference is detected.
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<release>17</release>
<compilerArgs>
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.38.0</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>0.12.7</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>After enabling NullAway, the following code fails to compile:
@NullMarked
public class PigOrderController {
@GetMapping("/orders/{username}")
public String getOrderStatus(@PathVariable String username) {
PigOrder order = pigOrderService.findByUsername(username); // @Nullable
// ❌ compile error: dereferencing @Nullable expression
return order.getStatus();
}
}Correct handling:
@NullMarked
public class PigOrderController {
@GetMapping("/orders/{username}")
public String getOrderStatus(@PathVariable String username) {
PigOrder order = pigOrderService.findByUsername(username); // @Nullable
return order != null ? order.getStatus() : "Order not found";
}
}Why choose @Nullable over Optional?
Although Java 8 provides Optional<T>, @Nullable has distinct advantages:
API compatibility : Adding @Nullable does not break existing callers, while changing a return type to Optional would.
Runtime overhead : Optional creates an extra object; @Nullable is compile‑time metadata with no runtime cost.
Usage scope : Optional is intended for return types, not for parameters or fields, where @Nullable is more natural.
Call‑site simplicity : Using @Nullable keeps null checks straightforward without the extra functional style of Optional.
Example comparison:
// Using Optional
return pigUserService.findUser(id)
.map(PigUser::getName)
.orElse("Unknown user");
// Using @Nullable
PigUser user = pigUserService.findUser(id);
return user != null ? user.getName() : "Unknown user";Conclusion
When you see @NullMarked and @Nullable in Spring Boot 4 code, they indicate the framework’s adoption of modern Java null‑safety practices. These annotations make the nullability of values explicit in the type system, and together with tools like NullAway they catch potential NPEs at compile time rather than at runtime.
Java Architecture Diary
Committed to sharing original, high‑quality technical articles; no fluff or promotional content.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
