Null-Safety, Now Included: JSpecify in OpenCQRS
We all know null is the age-old problem in Java, famously called "the billion dollar mistake." After decades of attempts to fix it, from JSR-305 annotations to new languages on the JVM like Kotlin, Java itself still doesn't have a universally adopted standard for nullability.
As a framework, OpenCQRS should avoid NPE bugs slipping through to production as much as possible. You rely on it, and it has to work. So we looked at what the Java ecosystem offers today and picked the best option we could find: JSpecify. Every module in the framework is now null-safe by default, enforced at compile time. This hardens not only our code, it also benefits yours. Let's have a look.
What Is JSpecify and Why We Chose It
Java has had nullability annotations for years. JSR-305 has @Nullable and @Nonnull,
and it found widespread adoption across libraries like Guava, SpotBugs, and Checkstyle.
But it was never truly finalized through the JCP. Without a formal spec, alternatives
like Jakarta's and JetBrains' own variants cropped up, and the landscape became
fragmented. Every tool and dependency ended up with a different set, if any. You were
stuck with multiple dependencies using different annotations, making it hard to impossible
to properly enforce null-safety.
JSpecify is different. It is a community-driven, tool-agnostic specification backed by a consortium that includes Google, JetBrains, Uber, Apple, and others. The annotations work across compilers, IDEs, and static analysis tools.
The key difference to previous annotations is the inversion of defaults. Older libraries
assumed everything was nullable unless you said otherwise. That meant annotating every
field, parameter, and return type that should not be null. Tedious and impractical, so
most code went unannotated.
JSpecify flips this with @NullMarked at package level. When a package is
@NullMarked, everything in it is non-null by default. Every possible null has to be
marked, hence the name. You annotate only the exceptions - the handful of places where
null is valid or unavoidable.
Why the inversion matters in practice
With previous annotations, most code was not annotated and therefore ambiguous. Tools
had to guess whether an unannotated parameter was meant to be nullable or not.
@NullMarked removes that ambiguity. Unannotated always means non-null. This
makes the contract explicit and lets tools like NullAway enforce it without guessing.
In addition to its more practical approach to annotating, community backing, and adoption in tools like NullAway, JSpecify was also added to the Spring Framework, which they wrote about here. Since OpenCQRS uses Spring, we now benefit from their annotations while adding our own null-safety. The more of the ecosystem adopts JSpecify, the more value it adds for everyone downstream.
JSpecify in Action - Concrete Examples
Two examples from the OpenCQRS codebase show what the type system now catches or expresses.
The first is AtomicReference<@Nullable ExecutorService> in our event handling
processor.
The AtomicReference itself is always non-null. It is a final field, initialized at
construction. But its content is nullable. When the processor is stopped, the reference
holds null. When it is running, it holds a live ExecutorService. Older annotation
libraries could not express this. You could say the field is non-null, or you could say
it is nullable, but you could not say "the container is non-null but its content might
be." JSpecify supports type-argument nullability, so you get exactly that.
The second example is @NullUnmarked on JPA entity classes. When a package is
@NullMarked, every type defaults to non-null. But JPA entities are populated through
reflection. The runtime instantiates the class and sets fields without going through your
constructor. From the type system's perspective, there is no code path that sets the
fields before they could be read. You could annotate every field as @Nullable, but that
would be misleading. Most fields are non-null after the entity is loaded. The pragmatic
answer is @NullUnmarked on the entity class. It opts the class out of null
checking because the framework's reflection-based lifecycle does not participate in the
type system's guarantees. It is a blunt instrument, it also hides fields that are
genuinely nullable even after loading, but for entities where most fields are non-null
post-load, it is the best pragmatic compromise.
These are not edge cases. Building a framework on top of Spring, JPA, and other reflection-heavy libraries means running into exactly these situations. The type system adds value where it can, and steps aside where it cannot.
When you might not want to opt out at entity level
@NullUnmarked on the whole class is the right call for most JPA entities, but not
all. If your entity has fields that track optional state transitions, like a
cancelledAt timestamp that is only set when the entity goes through cancellation,
opting out the entire class hides that nullability. In those cases, you might want to
annotate individual fields as @Nullable instead, or split the entity into a loaded
part and a nullable part. The trade-off depends on how many fields are genuinely
nullable in your domain.
What This Means for You
There is an immediate benefit you get from our adoption of JSpecify without any configuration. IntelliJ IDEA (CE) 2025.3 and later automatically picks up JSpecify annotations from your dependencies and shows you nullability hints inline. No extra dependencies. If you depend on OpenCQRS, your IDE already tells you which parameters are nullable. It won't prevent you from compiling unsafe code, but it is already a useful heads-up.
For full compile-time safety, you need three things: the JSpecify annotation library, the
Error Prone Gradle plugin, and
NullAway as an Error Prone
checker. Put @NullMarked on your packages, configure NullAway with JSpecify mode enabled,
and the moment you pass null where OpenCQRS expects a non-null value, the build fails. You
annotate only the places where null is actually a valid value. Our
build scripts
show a working example.
What is Error Prone and how does NullAway fit in?
Error Prone is a static analysis tool that hooks into the Java compiler. It runs additional checks during compilation and can report warnings or errors. NullAway is an Error Prone plugin that specializes in null-safety. You add both to your build, and NullAway becomes one of the checks Error Prone runs. This means NullAway integrates directly into your existing compilation pipeline.
Kotlin gets the same compile-time safety for free. Starting with Kotlin 1.8.20,
@NullMarked and @Nullable are understood natively, and as of 2.1.0 the compiler
emits errors for nullability violations by default. No extra configuration needed.
Conclusion
JSpecify is tool-agnostic, community-driven, and adopted by Spring Boot 4.x. For OpenCQRS, it means the framework is more reliable. For you, it means clearer API contracts and fewer surprises at runtime.
Check out the OpenCQRS repository on GitHub
to see how we use it. The @NullMarked annotations in package-info.java files and the
NullAway configuration in the build scripts are a good place to start.