Set up Spring Boot for non-null-by-default Java
· constraint
Whether a field, return value or parameter can be absent or not has semantic meaning. Yet in Java’s type system, this meaning cannot be modeled explicitly — unlike in Kotlin, for example.
Every object reference could be null. Thus, NullPointerExceptions are common and reasoning about the code becomes difficult for devs and AI agents alike.
In this article we explain how to:
- configure a Maven Java project to be non-null and
finalby default at compile time - explicitly model nullability
- deal with nullability during deserialization from JSON or the database
Explicit Nullability
JSpecify is a consensus-driven standard for annotating Java code with nullability information. It lets us enrich the type system.
The only two annotations we use in our code are @NullMarked and @Nullable.
When you apply @NullMarked to a module, package, class, or method, it means that unannotated types in that scope are treated as not null.
We develop a non-null by default approach.
In this project, every package has a package-info.java annotated with @NullMarked.
The annotation is not inherited by subpackages, so we annotate each package explicitly.
Add the dependency:
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>${jspecify.version}</version>
</dependency>
NullMarked by Default
Creating a package-info.java for each package with the right annotation is cumbersome.
We enforce this convention using the null-markeder library in a test:
class NullSafetyTests {
private static final String ROOT_PACKAGE = "li.mise";
@Test
void everyPackageIsNullMarked() {
JavaClasses classes = new ClassFileImporter().importPackages(ROOT_PACKAGE);
JavaPackage rootPackage = classes.getPackage(ROOT_PACKAGE);
List<String> violations =
rootPackage.getSubpackagesInTree().stream()
.filter(pkg -> !pkg.isAnnotatedWith(NullMarked.class))
.map(pkg -> pkg.getName() + " is not annotated with @NullMarked")
.collect(Collectors.toList());
if (!violations.isEmpty()) {
// Generates the missing package-info.java
// or only add the annotation if file already exists (will remove comments!)
PackageInfoGenerator.main(ROOT_PACKAGE);
}
assertThat(violations).isEmpty();
}
}
Compile-Time Safety
We can annotate all we want. The developer can still assign null to a non-null variable. The IDE might mark it as a warning, yet it will still compile.
To avoid this, we fail the compilation, if the nullability rules are violated.
Extending the Java Compiler with Error Prone and NullAway
Note: replace $YOUR-BASE-PACKAGE with your project’s base package.
<plugin>
<!--
Only declared here (not in the base build): with this profile inactive
(-Dquick), Maven still compiles via its default lifecycle binding, using
maven.compiler.release/project.build.sourceEncoding from <properties> - just
without Error Prone, NullAway, or -Werror slowing the compile down.
-->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<encoding>UTF-8</encoding>
<compilerArgs combine.children="append">
<!-- https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html -->
<!--
Enable all warnings
We turn off:
- 'serial' because we don't use Java serialization (and all it does is complain about the serialVersionUID)
- 'processing' because it complains about every annotation
-->
<arg>-Xlint:all,-serial,-processing,-varargs</arg>
<!-- Terminate compilation when *warnings* occur -->
<arg>-Werror</arg>
<!--
Error Prone Config: https://errorprone.info/docs/flags
Mainly based on: https://github.com/PicnicSupermarket/error-prone-support/blob/master/pom.xml
-->
<arg>
-Xplugin:ErrorProne -Xep:Var:ERROR -Xep:StringSplitter:OFF
<!--
NullAway Configs
https://github.com/uber/NullAway/wiki/Configuration
-->
-XepOpt:NullAway:AnnotatedPackages=$YOUR-BASE-PACKAGE
<!--
Calling .get() on an Optional value that hasn't been previously tested with Optional.isPresent(...)
will result in an error.
https://github.com/uber/NullAway/wiki/Configuration#optional-emptiness-check
-->
-XepOpt:NullAway:CheckOptionalEmptiness=true
-XepOpt:IdentifierName:AllowInitialismsInTypeName=true
-XepOpt:InlineMe:SkipInliningsWithComments=false
<!-- https://github.com/google/error-prone/issues/2910 -->
-XepOpt:Nullness:Conservative=false
<!-- We don't target JDK 8. -->
-Xep:Java8ApiChecker:OFF
<!-- We don't target Android. -->
-Xep:StaticOrDefaultInterfaceMethod:OFF
<!-- We generally discourage `var` use. -->
-Xep:Varifier:OFF
<!-- Yoda conditions are not always more readable than the alternative. -->
-Xep:YodaCondition:OFF
-XepOpt:CheckReturnValue:CheckAllConstructors=true
<!-- Append additional custom arguments. -->
${error-prone.patch-args}
</arg>
<!--
The Error Prone plugin makes certain
assumptions about the state of the AST at the
moment it is invoked. Those assumptions require
the `simple` compile policy. This flag may be
dropped after resolution of
https://bugs.openjdk.java.net/browse/JDK-8155674.
-->
<arg>-XDcompilePolicy=simple</arg>
<!--
Similarly, Error Prone requires that flow
analysis has been performed, e.g. to determine
whether variables are effectively final. This
flag may be dropped if it ever becomes the
default. See
https://bugs.openjdk.org/browse/JDK-8134117.
-->
<arg>--should-stop=ifError=FLOW</arg>
</compilerArgs>
<annotationProcessorPaths>
<!--
- Analyses AST to spot bugs: https://errorprone.info/bugpatterns
- Can be extended with custom or third-party bug patterns: https://error-prone.picnic.tech/
- Can automatically patch code not matching a patter
- Can be extended with custom or third-part patchers
- Install IJ plugin: https://plugins.jetbrains.com/plugin/7349-error-prone-compiler
- Configure Settings | Compiler | Java Compiler | Use compiler: Javac with error-prone
- make sure Settings | Compiler | Use external build is NOT selected.
-->
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>${error-prone-core.version}</version>
</path>
<!--
- Helps eliminate NullPointerExceptions
- Requires error prone
See for more: https://github.com/uber/NullAway/wiki
-->
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>${nullaway.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
However, this will not work without additional configuration, because Error Prone accesses JDK modules that are no longer exposed by default.
Thus, you also need to add to ./.mvn/jvm.config
--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-UNNAMED
Final by Default
Error Prone can enforce that modifiable local variables and parameters are explicitly marked with @Var.
In practice, this gives you a final-by-default style for locals and parameters.
Integer x = 1;
x = 2; // will not compile
@Var Integer y = 3;
y = 4; // will compile
The annotation is provided by the following library:
<dependency>
<!-- Provides annotations such as @Var to tell error-prone something is non-constant -->
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_annotations</artifactId>
<version>${error-prone-core.version}</version>
<scope>compile</scope>
</dependency>
Disable quality check while hacking
All those build-time checks can be irritating when you (or your agent) is simply trying things out before polishing.
Thus, we define quality-check plugins in a dedicated Maven profile called qa.
qa is active if and only if the quick property is not set:
<profiles>
<profile>
<!--
All quality-gate plugins live here instead of in <build><plugins>. Running with
-Dquick (which deactivates this profile, see its activation below) skips every one of them
and falls back to a bare `javac` compile plus tests - handy while iterating locally.
Active by default; add -Dquick to disable it, e.g. `./mvnw verify -Dquick`.
-->
<id>qa</id>
<activation>
<property>
<name>!quick</name>
</property>
</activation>
<build>
<plugins>
<!-- error prone; spotless; jacoco; etc. -->
</plugins>
</build>
</profile>
</profiles>
Handling Deserialization
Error Prone with NullAway handles compile-time null safety inside the code it analyzes. This removes many manual null checks because passing a nullable value where a non-null value is required fails the build.
However, what about cases where we dynamically call a method or use reflection to set a field?
Handling unexpected null in incoming JSON
Imagine we have a REST API to create users that needs a name and optionally a birthday.
The Dto might look like record CreateUserRequestDto(String name, @Nullable LocalDate birthday) {}.
NullAway would assume that name is never null. However, what if we receive an empty JSON object?
A naive approach is to add explicit null checks like:
import java.util.Objects;
record CreateUserRequestDto(String name, @Nullable LocalDate birthday) {
CreateUserRequestDto {
Objects.requireNonNull(name);
}
}
The drawbacks are boilerplate and short-circuiting validation. Imagine you have more than one non-null field where you received no value. With the current approach you would only give feedback on one violation. Bean Validation or Jackson itself can bundle all violations.
But, we do not want to annotate every non-null property with Java Bean Validation @NotNull nor with
Jackson’s @JsonProperty(required = true) annotation.
Instead, Jackson should learn about our non-null by default approach.
We solve this with the jackson-jspecify library. Adding its Spring Boot starter configures Jackson automatically for this behavior.
Handling Unexpected Null in JPA Entities
Imagine the following two cases. We expand the schema with a new attribute and fill it only for new entries. The existing rows get no default value or backfill.
Or someone bypasses the application logic and directly mutates the database.
If we load such entries, an attribute modeled as non-null could still be null!
We avoid this by explicitly checking the nullability after we load an entity and before we persist it back to the database.
The org.jmolecules.integrations:jmolecules-jpa library has a helper class for this.
@PostLoad
void postLoad() {
// In case someone did something funny in the database
// or added a new field without backfilling
JMoleculesJpa.verifyNullability(this);
}
@PrePersist
void prePersist() {
// In case someone did something funny with reflection
// the rest is caught by NullAway
JMoleculesJpa.verifyNullability(this);
}