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.

Java Architecture Diary
Java Architecture Diary
Java Architecture Diary
How JSpecify and NullAway Bring Compile‑Time Null Safety to Spring Boot 4

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";
    }
}
0GNZa2
0GNZa2

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.

backend developmentSpring Bootnull-safetyJSpecifyNullAway
Java Architecture Diary
Written by

Java Architecture Diary

Committed to sharing original, high‑quality technical articles; no fluff or promotional content.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.