I would strongly suggest you stop using this pattern. It's got all sorts of problems:
Basic errors in the code
Your NULL_ID field isn't final
which it clearly should be.
Null object vs. Empty object
There are 2 concepts that seem similar or even the same but they aren't.
There's the unknown / not found / not applicable concept. For example:
Map<String, Id> studentIdToName = ...;
String name = studentIdToName.get("foo");
What should name
be if "foo"
is not in the map?
Not so fast - before you answer: Well, maybe ""
- that would lead to all sorts of problems. If you wrote code that mistakenly thinks the id used is definitely in this map, then it's a fait accompli: This code is bugged. Period. All we can do now is ensure that this bug is dealt with as 'nicely' as possible.
And saying that name
is null
here, is strictly superior: The bug will now be explicit, with a stack trace pointing at the offending code. Absence of stack trace is not proof of bug free code - not at all. If this code returns the empty string and then sends an email to a blank mail address with a body that contains an empty string where the name should be, that's much worse than the code throwing an NPE.
For such a value (not found / unknown / not applicable), nothing in java beats null
as value.
However, what does occur quite often when working with APIs that are documented to perhaps return null
(that is, APIs that may return 'not applicable', 'no value', or 'not found'), is that the caller wants to treat this the same as a known convenient object.
For example, if I always uppercase and trim the student name, and some ids are already mapped to 'not enrolled anymore' and this shows up as having been mapped to the empty string, then it can be really convenient for the caller to desire for this specific use case that not-found ought to be treated as empty string. Fortunately, the Map
API caters to this:
String name = map.getOrDefault(key, "").toUpperCase().trim();
if (name.isEmpty()) return;
// do stuff here, knowing all is well.
The crucial tool that you, API designer, should provide, is an empty object.
Empty objects should be convenient. Yours is not.
So, now that we've established that a 'null object' is not what you want, but an 'empty object' is great to have, note that they should be convenient. The caller already decided on some specific behaviour they want; they explicitly opted into this. They don't want to then STILL have to deal with unique values that require special treatment, and having an Id
instance whose id
field is null fails the convenience test.
What you'd want is presumably an Id that is fast, immutable, easily accessible, and has an empty string for id. not null. Be like ""
, or like List.of()
. "".length()
works, and returns 0. someListIHave.retainAll(List.of())
works, and clears the list. That's the convenience at work. It is dangerous convenience (in that, if you weren't expecting a dummy object with certain well known behaviours, NOT erroring on the spot can hide bugs), but that's why the caller has to explicitly opt into it, e.g. by using getOrDefault(k, THE_DUMMY)
.
So, what should you write here?
Simple:
private static final Id EMPTY = new Id("");
It is possible you need for the EMPTY value to have certain specific behaviours. For example, sometimes you want the EMPTY object to also have the property that it is unique; that no other instance of Id can be considered equal to it.
You can solve that problem in two ways:
- Hidden boolean.
- by using EMPTY as an explicit identity.
I assume 'hidden boolean' is obvious enough. a private boolean field that a private constructor can initialize to true, and all publically accessible constructors set to false.
Using EMPTY as identity is a bit more tricky. It looks, for example, like this:
@Override public boolean equals(Object other) {
if (other == null || !other.getClass() == Id.class) return false;
if (other == this) return true;
if (other == EMPTY || this == EMPTY) return false;
return ((Id) other).id.equals(this.id);
}
Here, EMPTY.equals(new Id(""))
is in fact false, but EMPTY.equals(EMPTY)
is true.
If that's how you want it to work (questionable, but there are use cases where it makes sense to decree that the empty object is unique), have at it.
NULL_ID
asstatic final
, there is no concurrency problem. The conditionNULL_OBJECT != null
can only evaluate tofalse
within the class initializer which is safe. But that’s not the “Null Object” design pattern. The Null Object design pattern is supposed to prevent client code from encounteringnull
reference related problems. But yourrecord
’sid()
method still may returnnull
, just the same way arecord
allowingnull
would do. – Hydrocele