Null Safety with JSpecify
NullPointerException is the most common runtime error in Java. JSpecify is the standardized solution — annotate your APIs and get compile-time null safety. Spring Framework 7 adopts JSpecify throughout, and your code can too.
The Problem
// Is this safe? You don't know without reading the implementation.
public Order findById(UUID id) {
return repository.findById(id).orElse(null); // returns null!
}
// The caller has no warning:
Order order = service.findById(id);
order.cancel(); // ← NullPointerException if not found
Without null annotations, every method call is potentially null — you write defensive code everywhere, or you miss a case and get a NPE in production.
JSpecify
JSpecify is a standard for null annotations, supported by:
- IntelliJ IDEA (compile-time warnings)
- NullAway (compile-time error via ErrorProne)
- Kotlin (interoperability)
- Spring Framework 7
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
</dependency>
Core Annotations
@NullMarked — Package-Level Declaration
// package-info.java — applies to the entire package
@NullMarked
package com.devopsmonk.order.service;
import org.jspecify.annotations.NullMarked;
@NullMarked means: in this package, all types are non-null by default. You only annotate the exceptions (@Nullable).
@Nullable — Opt-Out
@NullMarked
package com.devopsmonk.order.service;
// This package is @NullMarked — everything non-null by default
@Service
public class OrderService {
// Return type is @NonNull by default (due to @NullMarked)
public Order findById(UUID id) {
return orderRepository.findById(id).orElseThrow(() ->
new OrderNotFoundException(id));
}
// Explicitly nullable return
public @Nullable Order findByIdOrNull(UUID id) {
return orderRepository.findById(id).orElse(null);
}
// Nullable parameter
public Order createOrder(CreateOrderRequest request, @Nullable UUID couponId) {
// couponId may be null — handle it
if (couponId != null) {
applyCoupon(request, couponId);
}
return buildAndSave(request);
}
}
@NonNull — Explicit (Less Common)
// @NonNull is the default in @NullMarked packages, but useful in non-marked packages
public @NonNull Order findById(@NonNull UUID id) { ... }
Applying to Spring Boot Code
Entities
@NullMarked
package com.devopsmonk.order.domain;
@Entity
@Table(name = "orders")
public class Order {
@Id
private UUID id;
@Column(nullable = false)
private UUID customerId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@Nullable // nullable in DB schema too
@Column
private String couponCode;
// Getters return non-null (per @NullMarked)
public UUID getId() { return id; }
public UUID getCustomerId() { return customerId; }
public OrderStatus getStatus() { return status; }
// Nullable getter — caller must handle null
public @Nullable String getCouponCode() { return couponCode; }
}
DTOs (Records)
@NullMarked
package com.devopsmonk.order.api;
// Record components are non-null by default (in @NullMarked package)
public record CreateOrderRequest(
UUID customerId,
List<OrderItemRequest> items,
@Nullable String couponCode, // optional
@Nullable String shippingNote // optional
) {}
Repository Layer
@NullMarked
package com.devopsmonk.order.repository;
public interface OrderRepository extends JpaRepository<Order, UUID> {
// Spring Data derives null from the method signature
// Optional<> — clearly handles absent case
Optional<Order> findByIdAndCustomerId(UUID id, UUID customerId);
// Returns null if not found (annotate explicitly)
@Nullable Order findTopByCustomerIdOrderByCreatedAtDesc(UUID customerId);
}
Service Layer
@NullMarked
package com.devopsmonk.order.service;
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository;
// Non-null return — throws if not found
public Order findById(UUID id) {
return repository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
// Nullable return — caller decides how to handle absent case
public @Nullable Order findLatestForCustomer(UUID customerId) {
return repository.findTopByCustomerIdOrderByCreatedAtDesc(customerId);
}
// Demonstrates caller handling nullable
public OrderSummary getCustomerOrderSummary(UUID customerId) {
Order latest = findLatestForCustomer(customerId); // nullable
String latestOrderId = latest != null ? latest.getId().toString() : "none";
return new OrderSummary(customerId, latestOrderId);
}
}
NullAway: Compile-Time Enforcement
IntelliJ IDEA shows warnings. NullAway turns them into compile errors:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR
-XepOpt:NullAway:AnnotatedPackages=com.devopsmonk
-XepOpt:NullAway:JSpecifyMode=true</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>0.11.0</version>
</path>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.28.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Now this is a compile error:
public void processOrder(UUID orderId) {
Order order = orderService.findLatestForCustomer(orderId); // @Nullable
order.cancel(); // ❌ COMPILE ERROR: 'order' may be null
// NullAway reports: Unboxing of findLatestForCustomer() may produce NullPointerException
}
Fix:
public void processOrder(UUID orderId) {
Order order = orderService.findLatestForCustomer(orderId);
if (order != null) {
order.cancel();
}
// Or:
Optional.ofNullable(orderService.findLatestForCustomer(orderId))
.ifPresent(Order::cancel);
}
Spring Framework 7 Integration
Spring Framework 7 is itself annotated with JSpecify. When you call Spring APIs, IntelliJ warns you when you misuse a nullable result:
// Spring's Environment.getProperty() returns @Nullable
@Autowired Environment env;
String port = env.getProperty("server.port"); // @Nullable return
int portInt = Integer.parseInt(port); // ⚠️ IntelliJ warns: 'port' may be null
// Fix:
String port = env.getProperty("server.port", "8080"); // with default — non-null
Testing Null Safety
Test that null contracts are enforced:
class OrderServiceNullSafetyTest {
@Test
void findByIdThrowsForNonExistentOrder() {
// Non-null contract: never returns null, throws instead
assertThatThrownBy(() -> orderService.findById(UUID.randomUUID()))
.isInstanceOf(OrderNotFoundException.class);
}
@Test
void findLatestReturnsNullWhenNoOrders() {
// Nullable contract: returns null when no orders exist
@Nullable Order result = orderService.findLatestForCustomer(UUID.randomUUID());
assertThat(result).isNull();
}
@Test
void createOrderHandlesNullCoupon() {
// Nullable parameter: null coupon is valid
CreateOrderRequest request = new CreateOrderRequest(
customerId, items, null, null); // null coupon OK
Order order = orderService.createOrder(request);
assertThat(order).isNotNull();
}
}
Migrating Existing Code
Don’t annotate everything at once. Apply @NullMarked package by package:
- Start with leaf packages (no other code depends on them)
- Add
package-info.javawith@NullMarked - Fix the NullAway errors (usually: add
@Nullablewhere methods genuinely return null, or changeorElse(null)toorElseThrow()) - Move up the dependency graph — annotate packages whose dependencies are already annotated
Typical order: domain/ → repository/ → service/ → api/
What You’ve Learned
@NullMarkedon a package makes all types non-null by default — annotate exceptions with@Nullable- NullAway + ErrorProne turns null safety violations into compile errors — NPEs caught before runtime
- Spring Framework 7 APIs are annotated with JSpecify — IDE warns on misuse of nullable return values
- Test null contracts explicitly: non-null returns throw on absent values;
@Nullablereturns return null - Migrate package by package — don’t try to annotate the whole codebase at once
- Design APIs to return
Optional<T>or throw exceptions rather than returning null — use@Nullableonly for opt-in cases
Next: Article 58 — API Versioning in Spring Boot 4 — strategies and implementation for versioning your REST API.