Tony Hoare called the null reference his “billion dollar mistake” back in 2009, and every Java developer has paid their share of that bill in NullPointerException stack traces at 2am. Spring’s own answer for the last decade was a pair of annotations — @Nullable and @NonNull in org.springframework.lang — that IDEs could use for hints, but that never had a real specification behind them and couldn’t express nullability on generics, arrays, or type parameters at all.
As of Spring Framework 7.0 and Spring Boot 4.0, that’s over. Spring’s own team spent months re-annotating essentially the entire portfolio with JSpecify, a genuinely specified, tool-neutral set of nullability annotations built jointly by Google, JetBrains, Google’s Error Prone team, and others. This guide walks through what actually changed, how to migrate your own code, and — the part most write-ups skip — how to turn nullability from an IDE suggestion into a real compile-time build failure using NullAway, with a genuine null-safety bug caught and fixed on a real build.
Where this guide is going
- 1. What JSpecify actually is — and why Spring’s old annotations weren’t enough.
- 2. Your first @Nullable, runnable — the core vocabulary in a standalone file.
- 3. Migrating from org.springframework.lang — the position difference that trips people up.
- 4. Enforcing it at compile time with NullAway — a real nullability bug, a real build failure.
- 5. What’s actually null-safe across the Spring portfolio — the real list, not marketing copy.
- 6. The Kotlin interop win — goodbye platform types.
- 7. IDE support — what you get without NullAway at all.
- 8. The migration checklist
What JSpecify actually is
Spring’s old annotations were declaration-style: @Nullable sat on a field, parameter, or method, following JSR 305 semantics that were never formally finished as a spec. That’s workable for the simple cases — “this parameter can be null” — but it has no vocabulary for “this List<String> can be null, but its elements can’t” or the reverse. JSpecify annotations are type-use: they attach to the type itself, which means they compose correctly with generics, arrays, and varargs in a way declaration-style annotations structurally cannot.
The other half of the pitch is tooling neutrality. JSpecify isn’t IntelliJ’s annotations, or Google’s, or Spring’s — it’s a joint spec with a canonical dependency (org.jspecify:jspecify) and no split-package conflicts between competing vendor annotation jars. Spring’s own team, IntelliJ IDEA, and the NullAway static checker all read the exact same annotations and agree on what they mean.
| Feature | Spring’s Nullable (pre-7) | JSpecify |
|---|---|---|
| Specification | JSR 305, never formally finished | Formally specified, jointly maintained (Google, JetBrains, others) |
| Annotation style | Declaration-style (sits on the member) | Type-use (attaches to the type itself) |
| Generic types | Can’t distinguish a nullable list from a list of nullable elements | List<@Nullable String> vs @Nullable List<String> are distinct |
| Arrays & varargs | Not expressible | Fully supported |
| Kotlin interop | Platform types (String!) at the boundary | Real String? / String types |
| Compile-time enforcement | None built in | Native support via NullAway |
Your first @Nullable, runnable
Three annotations cover almost everything you’ll write day to day: @Nullable (this type usage can be null), @NonNull (this type usage cannot — rarely needed explicitly, see below), and @NullMarked, which flips the default for an entire package or class so that everything is non-null unless explicitly marked @Nullable. That last one matters: without it, plain Java has no opinion on nullability at all, and every single type usage is technically unknown.
@NullMarked
package com.ankurm.nullsafety;
import org.jspecify.annotations.NullMarked;
With @NullMarked on the package, a class in it looks like this — only the return type that can actually be absent gets annotated:
package com.ankurm.nullsafety;
import org.jspecify.annotations.Nullable;
import java.util.Map;
public class AccountService {
private final Map<String, String> emailsById = Map.of("1", "[email protected]");
// Explicitly nullable return type: not every account has an email on file.
public @Nullable String findEmail(String accountId) {
return emailsById.get(accountId);
}
}
Notice the position: public @Nullable String findEmail(...), not @Nullable public String findEmail(...). That’s the type-use placement — the annotation sits directly against the type it modifies. It compiles either way (Java’s grammar is permissive about annotation placement on a method), but conventionally it goes right before the type it describes, and it’s non-negotiable once generics are involved: List<@Nullable String> (a list that may contain nulls) parses completely differently from @Nullable List<String> (a list reference that may itself be null) — a distinction the old declaration-style annotations had no way to express at all.
Migrating from org.springframework.lang
If your codebase already uses Spring’s own nullability annotations, the migration is mechanical for the common cases, with one real trap. Here’s the direct comparison:
| Old (deprecated in Framework 7) | New (JSpecify) | Notes |
|---|---|---|
org.springframework.lang.Nullable | org.jspecify.annotations.Nullable | Declaration-style → type-use. Position often needs to move. |
org.springframework.lang.NonNull | org.jspecify.annotations.NonNull | Rarely needed explicitly under @NullMarked — non-null is already the default. |
org.springframework.lang.NonNullApi (package) | org.jspecify.annotations.NullMarked (package) | Same intent, different spec underneath. |
org.springframework.lang.NonNullFields | Covered by @NullMarked | No separate fields-only variant — @NullMarked covers everything. |
The trap is the return-type position. A straight find-and-replace of the import statement leaves the annotation sitting in its old declaration-style spot, which for a return type is technically still legal Java but silently doesn’t mean what you think under some tooling combinations. Move it explicitly:
// Old (deprecated):
import org.springframework.lang.Nullable;
@Nullable
public String findEmail(String accountId) { ... }
// New (JSpecify) -- the annotation moves to sit against the return type:
import org.jspecify.annotations.Nullable;
public @Nullable String findEmail(String accountId) { ... }
For parameters and fields the position doesn’t change (@Nullable String email reads the same either way), so most of a mechanical migration is just the import swap. Return types are the ones worth a manual pass.
Enforcing it at compile time with NullAway
Annotations alone are documentation — nothing stops you from dereferencing a @Nullable value without a null check unless something actually checks. That something is NullAway, Uber’s Error Prone-based static checker, and Spring’s own team explicitly recommends it for teams that want real safety rather than IDE hints. I wired it into a small Maven project using the nullability-maven-plugin, which auto-configures Error Prone and NullAway for you (based on the Spring team’s own reference demo):
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>am.ik.maven</groupId>
<artifactId>nullability-maven-plugin</artifactId>
<version>0.3.0</version>
<extensions>true</extensions>
<executions>
<execution>
<goals><goal>configure</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Then I planted a real bug — the exact mistake this whole feature exists to catch — in the AccountService from the previous section: a caller that dereferences the @Nullable result without checking it first.
public class AccountService {
private final Map<String, String> emailsById = Map.of("1", "[email protected]");
public @Nullable String findEmail(String accountId) {
return emailsById.get(accountId);
}
// BUG: dereferences the nullable result without a null check.
public int emailLength(String accountId) {
String email = findEmail(accountId);
return email.length();
}
}
Running mvn compile against this, unedited, produced a real build failure:
[INFO] [nullability] Configuring ErrorProne 2.47.0 and NullAway 0.13.1 for jspecify-demo (checking=MAIN)
[ERROR] COMPILATION ERROR :
[ERROR] AccountService.java:[20,20] error: [NullAway] dereferenced expression email is @Nullable
(see http://t.uber.com/nullaway )
[ERROR] Failed to execute goal ... Compilation failure
That’s a genuine compiler error, on a genuine build, for a bug that a plain javac compile would have accepted without complaint and shipped straight to production. Adding the null check that the annotation was telling the reader to add fixes it for real:
public int emailLength(String accountId) {
String email = findEmail(accountId);
if (email == null) {
return 0;
}
return email.length();
}
[INFO] [nullability] Configuring ErrorProne 2.47.0 and NullAway 0.13.1 for jspecify-demo (checking=MAIN)
[INFO] --- nullability:0.3.0:configure (default) @ jspecify-demo ---
[INFO] BUILD SUCCESS
That’s the difference between JSpecify as documentation and JSpecify as a real guarantee. Without NullAway (or an equivalent checker) in your build, @Nullable is a strong hint your IDE can show you. With it, a NullPointerException waiting to happen becomes a build failure before it ever reaches a code reviewer, let alone production.
What’s actually null-safe across the Spring portfolio
“We annotated everything” is the kind of claim that’s easy to write in a release blog and hard to verify. Here’s what’s actually confirmed as JSpecify-annotated as of Spring Framework 7.0 / Spring Boot 4.0, based on the project’s own release notes and module documentation rather than secondhand summaries:
| Module | Status |
|---|---|
| spring-core, spring-beans, spring-context | Fully annotated, @NullMarked at the package level |
| spring-web, spring-webmvc, spring-webflux | Fully annotated |
| spring-tx, spring-jdbc, spring-orm | Fully annotated |
| spring-test | Fully annotated |
| Spring Boot autoconfiguration modules | Annotated progressively across 4.0; check a given module’s package-info.java for @NullMarked if you need certainty |
| Third-party libraries you depend on (Jackson, Hibernate, etc.) | Not Spring’s to annotate — each project migrates on its own timeline, several have started |
The practical takeaway: don’t assume a class is null-annotated just because it’s in org.springframework. Check for @NullMarked on the package (in package-info.java) before you rely on the absence of @Nullable to mean “never null.” Under JSpecify’s default semantics, a type without @Nullable in a @NullMarked package means non-null; the same absence in a package that isn’t @NullMarked means “unspecified,” which is a meaningfully weaker guarantee.
The Kotlin interop win
If you’ve never touched Kotlin, this section is skippable — but if your team mixes Java and Kotlin against Spring APIs, this is arguably the single biggest practical upgrade in this whole release. Kotlin has enforced null-safety in its type system since 1.0. Calling into Java code that has no nullability metadata forces the Kotlin compiler to treat every returned reference as a platform type (displayed as String! rather than String or String?), which suppresses null-checking entirely for that value. That’s exactly the class of bug null-safety is supposed to prevent, silently reappearing at the Java/Kotlin boundary.
Kotlin has recognized JSpecify annotations directly since Kotlin 1.9 (stabilized further in 2.0+). With Spring’s APIs now genuinely JSpecify-annotated, a Kotlin caller sees real String? vs String types instead of platform types — the Kotlin compiler enforces the same nullability contract Spring’s Java code declares, no manual bridging or Spring-specific Kotlin extensions required.
// Kotlin caller, against a JSpecify-annotated Spring method
val service: AccountService = ...
// email is now genuinely String? -- the compiler forces a null check
val email: String? = service.findEmail("1")
val length = email?.length ?: 0
Before JSpecify, that same call would have typed email as a platform type, and email.length would have compiled fine right up until it threw at runtime on an account with no email on file.
IDE support
You don’t need NullAway wired into your build to get value out of this — that’s the enforcement layer, not the only layer. IntelliJ IDEA (2023.3+) reads JSpecify annotations natively and will flag likely null-dereferences with a warning underline as you type, the same way it always did for org.springframework.lang annotations, just with better precision on generics now that the annotations are type-use. Eclipse’s null analysis and the Checker Framework both have JSpecify support in progress or already shipped, depending on version.
The honest framing: IDE warnings catch mistakes while you’re writing code, on your machine, if you have the inspection enabled and pay attention to it. NullAway catches them for everyone, on every machine, on every build, and fails the build if ignored — which is why the previous section‘s setup is worth the extra ten minutes if you actually want the guarantee rather than the hint.

The migration checklist
- Replace
org.springframework.lang.Nullable/NonNullimports withorg.jspecify.annotations.Nullable/NonNull— a straightforward find-and-replace for most usages (see the mapping table above). - Watch return-type placement specifically:
@Nullableon a method’s return type needs to move from above the method to directly before the return type (public @Nullable String find(...)), because JSpecify annotations are type-use, not declaration-style. - Replace package-level
@NonNullApi+@NonNullFieldswith a single@NullMarkedinpackage-info.java— it covers both together under JSpecify’s semantics. - Don’t assume every Spring class is null-annotated just because it’s in
org.springframework; check for@NullMarkedon the specific package (see the portfolio status table above) before treating an unannotated type as guaranteed non-null. - If you want a real compile-time guarantee rather than an IDE hint, wire in NullAway — the
am.ik.maven:nullability-maven-pluginsetup validated in the section above gets you there with a smallpom.xmladdition. - If your team mixes Java and Kotlin against Spring APIs, re-check any code that previously worked around platform-type ambiguity (defensive
!!assertions, manual null checks that predate JSpecify) — the Kotlin compiler now enforces the real contract, and some of that old defensive code may be provably redundant. - Budget time for false-positive triage the first time you turn on NullAway — static null-checkers are conservative by design, and a codebase with no prior null-checking discipline will surface real findings, not just noise, on the first run.
An AI prompt to find nullability gaps in your project
Paste this into Claude, ChatGPT, Gemini, or Cursor with your repository in context.
You are auditing a Java/Spring codebase migrating to JSpecify null-safety
annotations (Spring Framework 7 / Spring Boot 4 have replaced
org.springframework.lang.Nullable/NonNull with org.jspecify.annotations
equivalents, which are type-use rather than declaration-style annotations).
Scan the project and report, in this order:
1. OLD ANNOTATIONS: every use of org.springframework.lang.Nullable,
NonNull, NonNullApi, or NonNullFields, with file:line. For each, give
the exact org.jspecify.annotations replacement, flagging any case where
the annotation is on a method return type (these need to move from
above the method to directly before the return type under JSpecify's
type-use placement rules).
2. MISSING NULL CHECKS: methods that accept or return a type later
dereferenced without a null check, in files that already import
org.jspecify.annotations.Nullable elsewhere in the project -- these are
the most likely real NullAway findings once enforcement is turned on.
3. PACKAGE-LEVEL COVERAGE: which packages have a package-info.java with
@NullMarked and which don't, so I know which parts of the codebase have
an enforceable contract versus "unspecified" nullability.
4. KOTLIN BOUNDARY (if applicable): Kotlin files calling into Java classes
in this project that are not yet @NullMarked -- these still resolve to
platform types on the Kotlin side.
For each finding give file:line and the exact fix. End with a suggested
NullAway rollout order (start with leaf modules with the fewest
dependents).
Frequently asked questions
Do I have to migrate off org.springframework.lang immediately?
No — Spring’s old annotations still compile and still work as IDE hints. But they’re deprecated, they don’t get any of the type-use precision JSpecify offers, and new Spring APIs are annotated with JSpecify only, so the two will increasingly disagree the longer you wait.
Does JSpecify replace Optional?
No, they solve different problems. Optional<T> is a return-type wrapper meant to force callers to handle absence explicitly; JSpecify annotations describe nullability on ordinary references, including parameters, fields, and generic type arguments, none of which Optional covers. Plenty of well-designed APIs use both.
Do I need NullAway, or is the IDE warning enough?
Depends on what guarantee you want. IDE warnings (see the section above) catch mistakes for whoever’s looking at the warning at the time, on whichever IDE they’re using, if the inspection is even enabled. NullAway, wired into the build as validated in the section above, catches them for every contributor, on every machine, on every CI run, and fails the build rather than relying on someone noticing a squiggly underline.
Will NullAway produce false positives on an existing codebase?
Likely, at least initially — static null-checkers are deliberately conservative, and code written before any nullability discipline existed will have patterns NullAway can’t prove are safe even when they are. Most teams roll it out module by module rather than turning it on globally on day one.
Is this JSpecify migration required to move to Spring Boot 4?
No. Spring Boot 4 runs fine whether or not you touch your own nullability annotations at all — this is an opt-in improvement to your own code’s safety, not a breaking change forced on you by the framework.
See also
- Spring Framework 6 to 7 Migration Guide
- Spring Boot 3 to 4 Migration Guide
- Spring Framework 7 API Versioning: The Complete Guide
- Undertow to Tomcat 11 Migration Guide
- Micrometer to OpenTelemetry: The Spring Boot 4 Observability Guide
Conclusion
JSpecify’s real contribution isn’t a new pair of annotations to learn — it’s that @Nullable finally means the same thing everywhere: to Spring, to IntelliJ, to Kotlin, and, if you wire it in, to your build. The migration itself is close to mechanical (swap the import, move return-type placement, replace the two package-level annotations with @NullMarked), which is exactly why it’s worth doing rather than leaving on the deprecated path. The part worth actually budgeting time for is turning on NullAway — that’s the step that converts a null-safety annotation from a comment into a guarantee, and I’d rather find out about a missing null check from a failed build on my machine than from a support ticket.