Introduction
Whilst working at Singtel, one of my tasks was to help a team in its efforts to modernize a WebLogic application to Spring Boot. While reviewing the code, I pointed out the danger of implicitly unboxing a potentially null value.
The issue at stake was the risk of a null pointer exception caused by comparison behaviour around Long and long.
There was no clear consensus across the teams on when to use primitives and when to use wrappers, so I set out to answer the question properly and share a practical recommendation.
What follows is my analysis, using Long and long as the main example. The same reasoning applies to the other primitive types and their wrappers.
When I mention Long with an uppercase L, I mean the wrapper type, whereas long refers to the primitive.
Here be Dragons
The first trap is that Long and long may look similar, but they behave very differently.
If you compare two Long objects with ==, you are comparing references, not values.
// this prints true
public static void main(String[] args) {
Long a = 5L;
Long b = 5L;
out.println(a == b);
}
// but this prints false
public static void main(String[] args) {
Long a = 386L;
Long b = 386L;
out.println(a == b);
}
Java caches some boxed values, so small wrapper instances may reuse the same reference, while larger ones typically do not. In the first example, a and b happen to reference the same object. In the second, they do not.
For Long, that cache covers values in the range -128 to 127.
Things become more subtle when comparing a Long with a long. In that case, Java unboxes the wrapper to a primitive before comparison, so both of these are true:
public static void main(String[] args) {
Long a = 5L;
long b = 5;
out.println(a == b);
}
public static void main(String[] args) {
Long a = 386L;
long b = 386L;
out.println(a == b);
}
But this convenience comes with a cost: if the wrapper is null, unboxing throws an NPE:
public static void main(String[] args) {
Long a = getCustomerId(); // getCustomerId returns null
out.println(a == 0);
}
Likewise:
public static void main(String[] args) {
final long DEFAULT_COUNTRY_CODE = 65;
Long countryCode = getCountryCode(); // getCountryCode returns null
out.println(DEFAULT_COUNTRY_CODE == countryCode);
}
Primitives are values, so == compares their values. Wrapper objects are references, so == compares object identity. When you mix a primitive and a wrapper, Java tries to bridge the gap by unboxing the wrapper. If that wrapper is null, the comparison fails with an NPE.
It is easy to make a mistake. Conceptually, Long and long feel almost the same. Semantically, they are not.
Prefer Value Equality
If you have wrapper objects and want to compare them by value, use value equality rather than reference equality. This example is safe and behaves as expected:
public static void main(String[] args) {
Long a = getCustomerId();
Long b = 386L;
long c = 386;
Long d = null;
if (a != null) {
out.println(a.equals(b));
out.println(a.equals(c));
out.println(a.equals(d));
}
}
As long as a is not null, equals() compares values safely, even when the other argument is null.
Be careful with Literals and Auto-Boxing
When a method such as equals() takes an Object, Java auto-boxes a primitive value into its corresponding wrapper type. That means 0 becomes an Integer, while 0L becomes a Long.
This code returns false, because countryCode.equals(0) compares a Long with an Integer:
public static void main(String[] args) {
final long DEFAULT_COUNTRY_CODE = 65;
Long countryCode = 0L;
if (countryCode == null || countryCode.equals(0)) {
countryCode = DEFAULT_COUNTRY_CODE;
}
out.println(countryCode.equals(DEFAULT_COUNTRY_CODE));
}
If the literal is written as 0L, the result is true, because you are now comparing a Long with a Long:
if (countryCode == null || countryCode.equals(0L)) {
countryCode = DEFAULT_COUNTRY_CODE;
}
So even when using equals() and auto-boxing, you still need to be precise about types.
Implicit Promotion
This is true, because both sides are primitives:
public static void main(String[] args) {
out.println(0 == 0L);
}
Java implicitly promotes the int to a long for the comparison.
The same thing happens in assignments. In an earlier example, 65 was assigned to DEFAULT_COUNTRY_CODE, which is a long, so Java promoted the int literal automatically. If you want to be explicit, write it as 65L.
Prefer Constants to Literals (Where Appropriate)
As mentioned, Java implicitly promotes primitive values when required.
Here, the literal 0 is promoted to a long when assigned to NO_STOCK. When NO_STOCK is later passed to equals(), it is auto-boxed to a Long.
This prints "no stock" if stockCount is null or 0L, as expected:
public static void main(String[] args) {
final long NO_STOCK = 0;
Long stockCount = getStockCount();
if (stockCount == null || stockCount.equals(NO_STOCK)) {
out.println("no stock");
}
}
Using a named constant can also make the comparison clearer.
Auto-Unboxing and Implicit Promotion
This example works as expected because stockCount is auto-unboxed to a long, the int literal 0 is implicitly promoted to a long, and the guard condition ensures that stockCount is not null before the comparison is evaluated.
public static void main(String[] args) {
Long stockCount = getStockCount();
if (stockCount == null || stockCount == 0) {
out.println("no stock");
}
}
This is a good idiom for comparing a wrapper with a primitive when null is a valid state. As a general rule, using the correct literal (0L) or a primitive variable of type long is better than relying on implicit promotion.
Casting
Casting can be used to make numeric conversions explicit. This is most useful when passing a value of a different
primitive type into equals().
public static void main(String[] args) {
Long stockCount = getStockCount();
Integer a = 0;
int b = 0;
// watch out for NPE if a is null
if (stockCount == null || stockCount == (long)a) {
out.println("no stock");
}
// watch out for NPE if a is null
if (stockCount == null || stockCount.equals((long)a)) {
out.println("no stock");
}
// not needed, the compiler does this for you
if (stockCount == null || stockCount == (long)b) {
out.println("no stock");
}
// useful when passing a primitive to equals()
if (stockCount == null || stockCount.equals((long)b)) {
out.println("no stock");
}
}
If a is null, casting it to long requires unboxing and will throw an NPE. If the source is already a primitive, the cast is safe. Casting is most useful when you need to coerce a primitive to the correct type for equals().
Assignment Has the Same Problem
The same issue also appears in assignments.
If a wrapper is null, assigning it to a primitive forces unboxing and throws an NPE.
public static void main(String[] args) {
Long a = null;
// this is safe
Long b = a;
// this throws an NPE
long c = a;
}
Method calls have the same risk:
private static long getStockCount(long productId) {
return 0;
}
private static Long getRatings(Long productId) {
return null;
}
public static void main(String[] args) {
Long productAId = 1L;
Long productBId = null;
// this is okay
long stockCountA = getStockCount(productAId);
// this throws an NPE (unboxing null argument)
long stockCountB = getStockCount(productBId);
// this is okay
Long productARatings1 = getRatings(productAId);
// this throws an NPE (unboxing null result)
long productARatings2 = getRatings(productAId);
}
So the problem is not limited to comparisons.
Any assignment or method call that forces unboxing can fail in exactly the same way.
Modern Java
Modern Java does provide ways to reduce some of these risks.
If a wrapper may be null, Optional can provide a concise way to substitute a safe default:
Long stockCount = getStockCount();
long safeStockCount = Optional.ofNullable(stockCount).orElse(0L);
Likewise, if you simply want a defaulted wrapper value, Objects.requireNonNullElse(...) is concise:
Long countryCode = getCountryCode();
Long safeCountryCode = Objects.requireNonNullElse(countryCode, 65L);
If you need to compare wrapper objects by value without risking an NPE, Objects.equals(...) is null-safe:
Long a = getCustomerId();
Long b = 386L;
if (Objects.equals(a, b)) {
out.println("same value");
}
These are useful tools, but they do not remove the underlying distinction between primitives and wrapper types. In a Spring Boot codebase, where wrappers are already pervasive, consistency still matters.
Avoid Primitives for Fields and Related Method Signatures
This is why, in Spring Boot applications, I generally prefer to keep fields and related method signatures aligned to wrapper types such as Long. If not, every transition between Long and long, Integer and int, and so on introduces another place where assignments and comparisons need to be guarded carefully. It is easy to make a mistake, especially when tired. Consistency is the first line of defence.
This is controversial. It runs against Joshua Bloch's advice in Effective Java (3rd edition, Item 61). And Bloch is right in principle: primitives should generally be preferred to boxed primitives where the latter are not required. They are more efficient, often easier to reason about, and avoid unnecessary object overhead.
However, in the context of a Spring Boot application, I often find it saner to trade a little efficiency for greater consistency. Wrapper types are pervasive in framework-heavy code: DTOs, entities, repositories, services, controllers, response models, generics, and data binding all tend to pull them into the design anyway. In that setting, avoiding casual mixing reduces the number of adaptation points and lowers the chance of accidental NPEs.
That does not mean primitives should never be used. They are well suited to indexing, number crunching, and other tightly scoped internal uses. My recommendation applies mainly to fields and the related methods and parameters that surround them in a generics-heavy framework. Java generics cannot use primitive types directly, and generics are pervasive in Spring Boot.
The Problem with Defaults: Presence vs. Validity
One of the strongest arguments for using wrapper types in Spring Boot is that they preserve the distinction between missing data and default values. When a framework like Jackson maps an incoming JSON request to a Java DTO, fields omitted by the client retain their Java default values.
Consider a simple InventoryDto:
// Using a primitive
public class InventoryDto {
private int stockLevel;
}
If a client sends a request that omits the stockLevel field, Java initializes the primitive to 0. Without explicit validation, this happens silently. This is dangerous because 0 is often a valid business value (e.g., "out of stock"). Your application cannot distinguish between a deliberate update to zero and a field that was simply missing from the request. This can lead to data corruption if existing values are overwritten by defaults. Even with validation, primitives collapse two different concerns into one:
public class InventoryDto {
@Min(1)
private int stockLevel; // Defaults to 0 if missing
}
If the field is omitted, @Min(1) triggers an error because 0 < 1. To the caller, this is confusing: they did not send a 0, they omitted the field entirely.
By contrast, using a wrapper type allows you to separate presence from validity:
// Using a wrapper
public class InventoryDto {
@NotNull
@Min(1)
private Integer stockLevel; // Remains null if missing
}
With an Integer, if the field is missing, the value is null.
Presence: An omitted field triggers the @NotNull violation ("This field is required").
Validity: A field explicitly sent as 0 triggers the @Min violation ("The value must be at least 1").
This precision is vital for building predictable APIs and maintaining data integrity. It ensures that your application state accurately reflects the intent of the caller, rather than falling back on the language-level default values imposed by Java. This is especially critical for foreign keys or ID fields, where a default 0 might refer to an entirely different (or non-existent) record.
Conclusion
When working inside a framework such as Spring Boot, you generally want to lean into its strengths. Spring Boot and its ecosystem make extensive use of generics, data binding, repositories, DTOs, and other abstractions where wrapper types such as Long and Integer are pervasive.
For that reason, my practical recommendation is simple: avoid mixing primitives and wrapper types casually. The more often you cross between them, the more opportunities you create for subtle null-related bugs, awkward comparisons, and accidental unboxing.
In framework-heavy application code, I therefore prefer wrapper types for fields and related method signatures in DTOs, entities, repositories, services, controllers, and similar layers. The fewer conversion boundaries you create, the fewer surprises you invite.