Compile-Time Null Safety in Quarkus: Stop NullPointerExceptions Before They Happen
Use JSpecify and NullAway to make your Java code null-safe at build time — no runtime surprises, no production crashes.
NullPointerExceptions still cause most production crashes in Java applications.
They are usually found too late — when tests miss them or users trigger an edge case.
You can avoid this by making nullability explicit and verifying it during compilation.
In this tutorial, you’ll use JSpecify for annotations and NullAway for static analysis in a small Quarkus REST service.
Background
null has been part of Java since day one. It’s simple but unsafe.
Most enterprise systems treat it inconsistently: sometimes as “not found,” sometimes as “optional,” and sometimes as “error.”
Runtime checks work, but they depend on discipline. Compile-time checks make it systematic.
Prerequisites
You need:
Java 17 or later
Maven 3.9+
Quarkus CLI or the Maven plugin
An IDE of your choice
Basic familiarity with Quarkus and JAX-RS
The Problem: Runtime NPE
You can walk through the tutorial or grab it from my Github repository.
Create a new Quarkus project
quarkus create app com.coffeeshop:null-safety-demo \
--extension=rest-jackson
cd null-safety-demoDefine the domain model
src/main/java/com/coffeeshop/model/User.java
package com.coffeeshop.model;
public record User(Long id, String name, String email) {
}A service that returns null
src/main/java/com/coffeeshop/service/UserService.java
package com.coffeeshop.service;
import java.util.List;
import com.coffeeshop.model.User;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class UserService {
private static final List<User> USERS = List.of(
new User(1L, “Alice”, “alice@example.com”),
new User(2L, “Bob”, “bob@example.com”));
public User findByEmail(String email) {
return USERS.stream()
.filter(u -> u.email().equalsIgnoreCase(email))
.findFirst()
.orElse(null); // returns null
}
}A resource that dereferences blindly
Rename the scaffolded GreetingResource to:
src/main/java/com/coffeeshop/UserResource.java
package com.coffeeshop;
import com.coffeeshop.model.User;
import com.coffeeshop.service.UserService;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path(”/users”)
@Produces(MediaType.APPLICATION_JSON)
public class UserResource {
@Inject
UserService userService;
@GET
@Path(”/by-email/{email}”)
public String getUser(@PathParam(”email”) String email) {
User user = userService.findByEmail(email);
return user.name(); // potential NPE
}
}Run and observe
./mvnw quarkus:dev
curl http://localhost:8080/users/by-email/unknown@example.comYou’ll get a 500 error with a NullPointerException in the logs.
This is the problem we want to eliminate.
Compile-Time Null Checks
4.1 Add dependencies
In pom.xml:
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
<scope>provided</scope>
</dependency>Configure NullAway and Error Prone
Still in pom.xml:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
<encoding>UTF-8</encoding>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-Xplugin:ErrorProne -XepOpt:NullAway:AnnotatedPackages=com.coffeeshop</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.44.0</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>0.12.12</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>Make sure to add the additional JVM flags that are required due to JEP 396: Strongly Encapsulate JDK Internals by Default.
add the following to the .mvn/jvm.config file:
--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMEDMark the package as null-safe by default
src/main/java/com/coffeeshop/package-info.java
@NullMarked
package com.coffeeshop;
import org.jspecify.annotations.NullMarked;Now, all types in this package are non-null by default.
Compile and check
./mvnw clean compileExpected output includes something like:
[WARNING] ... UserService.java:[17,9] [NullAway] returning @Nullable expression from method with @NonNull return typeThe compiler caught the issue before you ran the application.
Fixing the Code
Annotate the nullable return
In UserService.java:
import org.jspecify.annotations.Nullable;
public @Nullable User findByEmail(String email) {
return USERS.stream()
.filter(u -> u.email().equalsIgnoreCase(email))
.findFirst()
.orElse(null);
}Add a null check in the resource
UserResource.java:
package com.coffeeshop;
import org.jspecify.annotations.Nullable;
import com.coffeeshop.model.User;
import com.coffeeshop.service.UserService;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path(”/users”)
@Produces(MediaType.APPLICATION_JSON)
public class UserResource {
@Inject
UserService userService;
@GET
@Path(”/by-email/{email}”)
public @Nullable String getUser(@PathParam(”email”) String email) {
@Nullable User user = userService.findByEmail(email);
if (user == null) {
return null;
}
return user.name();
}
}@Nullable can’t be used on expressions like @Nullable user.name(). It’s only valid on types (parameters, return types, fields, local variable types).
Use @Nullable on the method return type (public @Nullable String) and add a null check before dereferencing user. This satisfies NullAway and prevents NPEs.
Compile again
./mvnw clean compileNow the build passes cleanly.
Run it:
./mvnw quarkus:devTry again:
curl -v http://localhost:8080/users/by-email/alice@example.com
→ Returns a 200 OK
curl -v http://localhost:8080/users/by-email/unknown@example.com→ Returns 204 No Content without throwing an exception.
The issue is gone.
Practical Notes
When to use @Nullable
Use it when a value might legitimately be missing:
findById,lookup, orsearchmethodsExternal APIs
User input that can be absent
Avoid sprinkling it everywhere. If a value should never be null, let the compiler enforce it.
Quarkus integration details
CDI injections are always non-null.
@ConfigPropertycan definedefaultValueto prevent nulls.@QueryParamand@PathParamcan be nullable — annotate explicitly.For reactive types:
Uni<@Nullable T>is supported and self-documenting.
Optional vs @Nullable
Optional<T> forces the caller to deal with missing values.@Nullable T lets the compiler enforce the contract while keeping the API minimal.
Use one consistently per layer.
CI/CD Integration and Team Practices
NullAway should run on every build, not just on developer machines.
The easiest way is to make it part of your regular Maven or Gradle compile phase.
In Maven, the configuration shown earlier already triggers NullAway during mvn compile.
To enforce it during automated pipelines, make sure your CI build runs:
mvn clean verify -Dmaven.compiler.failOnWarning=trueThis treats any NullAway finding as a hard build failure.
That’s the behavior you want — NPEs are not warnings.
If you use GitHub Actions, add a static analysis step to your workflow file:
- name: Build with NullAway checks
run: mvn -B clean verify -Dmaven.compiler.failOnWarning=trueIncremental Adoption
You can start small:
Use
@NullMarkedonly in new or refactored packages.Treat existing unannotated code as “unverified” until you touch it.
Combine with
spotlessorformatter-maven-pluginto keep annotation imports consistent.
This helps large teams migrate gradually without breaking every build.
Code Review Guidelines
Include null safety in your review checklist:
Every public method should declare nullability explicitly (
@Nullableor non-null by default).Return
Optional<T>or clearly documented@Nullableinstead of undocumented nulls.Avoid suppressing NullAway findings unless you have a strong reason.
Build Cache and IDE Sync
Both IntelliJ and VS Code handle NullAway via annotation processors.
If you use Quarkus Dev Mode, the incremental compiler won’t rerun NullAway after every change, so always run a full mvn compile before committing.
Static Analysis Reports
NullAway doesn’t produce fancy HTML reports. It fails fast with clear compiler errors.
Continuous Enforcement
The main rule:
If your build passes, null safety is guaranteed for the annotated packages.
No runtime overhead. No reflection. No false positives once configured correctly.
You write the same code , just only safer.
Compile-time null checking is simple to add and has no runtime overhead.
By marking packages with @NullMarked and enforcing checks with NullAway, you can stop NullPointerExceptions before they ever reach production.



