Real World Scenarios: The Optional Field That Wasn't Optional
A working reproduction of a real NullPointerException — a Sling Model's @PostConstruct only initializes a list when an @Optional injected field is present, but the getter assumes it always is. Includes the failing test that proves the crash and the null-safe fix.
Background reading: this scenario builds on AEM Sling Models: The Complete Guide, specifically the section on @Optional injection and @PostConstruct initialization order.
Problem Statement
A component rendering a grid of category cards took its card count from a dialog field — how many cards the author wanted to show. The field was marked @Optional on purpose, since the component shipped with a sensible built-in default and authors weren't required to touch that dialog field at all.
The model looked like this:
@Model(adaptables = Resource.class)
public class FeaturedCardGroup {
@Inject
@Optional
private String cardCount;
private List<Integer> cardIndices;
@PostConstruct
protected void init() {
if (cardCount != null) {
int count = Integer.parseInt(cardCount);
cardIndices = new ArrayList<>();
IntStream.range(0, count).forEach(cardIndices::add);
}
}
public List<Integer> getCardIndices() {
return List.copyOf(cardIndices);
}
}
Any page where the author left the card-count field blank — which was the common case, since it was optional and most authors never opened that dialog tab — threw a NullPointerException from getCardIndices() the moment HTL called it, taking down the whole component's render with a component error box instead of quietly falling back to a default.
Approach and Why
The bug is a classic gap between an @Optional injection and a @PostConstruct that only handles the "present" branch. @Optional on cardCount is a correct, deliberate choice — the field genuinely doesn't have to be authored. The mistake is entirely in what happens next: init() only assigns cardIndices inside the if (cardCount != null) branch, leaving it null whenever the optional field is absent, and getCardIndices() has no defense against that — List.copyOf(null) throws unconditionally, it doesn't return an empty list.
The fix has to do two things, not one: give cardIndices a real default when the field is absent, and make the getter itself resilient regardless of what init() did — because relying on every call site to remember to initialize every field correctly is exactly the kind of assumption that produces this bug in the first place.
POC
class FeaturedCardGroupTest {
@Test
void missingOptionalFieldCrashesTheGetter() {
FeaturedCardGroup model = new FeaturedCardGroup();
// cardCount left null, simulating an author who never touched the dialog field
callPostConstruct(model);
// Fails today: NullPointerException from List.copyOf(null)
assertThrows(NullPointerException.class, model::getCardIndices);
}
@Test
void presentFieldStillProducesTheExpectedCount() {
FeaturedCardGroup model = new FeaturedCardGroup();
setField(model, "cardCount", "4");
callPostConstruct(model);
assertEquals(List.of(0, 1, 2, 3), model.getCardIndices());
}
}
The first test documents the actual production failure directly, rather than describing it after the fact — it fails against the original code for precisely the right reason.
Implementation
@Model(adaptables = Resource.class)
public class FeaturedCardGroup {
private static final int DEFAULT_CARD_COUNT = 3;
@Inject
@Optional
private String cardCount;
private List<Integer> cardIndices = List.of();
@PostConstruct
protected void init() {
int count = DEFAULT_CARD_COUNT;
if (cardCount != null) {
try {
count = Integer.parseInt(cardCount);
} catch (NumberFormatException e) {
log.warn("cardCount '{}' is not a valid integer, falling back to default {}", cardCount, DEFAULT_CARD_COUNT);
}
}
cardIndices = IntStream.range(0, count).boxed().collect(Collectors.toUnmodifiableList());
}
public List<Integer> getCardIndices() {
return cardIndices;
}
}
Three changes, each closing a distinct gap:
- A field default (
List.of()) meanscardIndicesis nevernull, even before@PostConstructruns, or if some future refactor adds an early return toinit(). - A real fallback value (
DEFAULT_CARD_COUNT) means an absent dialog field behaves the way "optional" is supposed to behave — a sensible default, not a crash. - A guarded
Integer.parseIntmeans a dialog field containing something non-numeric (a stray space, a copy-paste artifact) degrades to the default instead of throwing a different, equally uncaught exception from the same method.
Both POC tests pass against this version — the previously-crashing case now returns the three-item default list instead of throwing.
Rollout Steps
- Deploy the fix — it's strictly additive in behavior (previously-crashing pages now render with the default card count instead of erroring), so no author-facing change is needed for existing content.
- Add the two POC tests, renamed to match the real model, to the permanent test suite guarding this class.
- Grep the rest of the codebase for other
@PostConstructmethods that only assign a field inside anifbranch keyed off an@Optionalfield, without anelse— this exact shape is easy to reproduce accidentally in any model with an optional dialog setting and a derived collection. - Consider whether
@Optionalfields backing derived collections should default the field itself at declaration (as done here) as a standing convention for new Sling Models, rather than relying on@PostConstructto always remember to do it.
Why This Approach Held Up
Fixing only the getter (guarding List.copyOf with a null check and returning List.of()) would have stopped the crash but silently produced a zero-card render for every un-configured page — different from the crash, but still not what "optional with a sensible default" was supposed to mean. Fixing only init() to always assign something, without also hardening the getter, would have left the class one future refactor away from reintroducing the same crash. Doing both together makes the class correct on its own terms, independent of whether every future change to init() remembers the optional case.
What's Next
Next in this series: a survey pass through the remaining bts repository files not yet covered by any chapter or scenario post.
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.