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:

  1. Start with leaf packages (no other code depends on them)
  2. Add package-info.java with @NullMarked
  3. Fix the NullAway errors (usually: add @Nullable where methods genuinely return null, or change orElse(null) to orElseThrow())
  4. Move up the dependency graph — annotate packages whose dependencies are already annotated

Typical order: domain/repository/service/api/

What You’ve Learned

  • @NullMarked on 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; @Nullable returns 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 @Nullable only for opt-in cases

Next: Article 58 — API Versioning in Spring Boot 4 — strategies and implementation for versioning your REST API.