N
Naveenr.dev
Chapter rw-23
9 min read•2026-07-25
📖 AEM - Adobe Experience Manager SeriesChapter rw-23 · 18 chapters

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:

java
@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

java
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

java
@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:

  1. A field default (List.of()) means cardIndices is never null, even before @PostConstruct runs, or if some future refactor adds an early return to init().
  2. 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.
  3. A guarded Integer.parseInt means 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

  1. 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.
  2. Add the two POC tests, renamed to match the real model, to the permanent test suite guarding this class.
  3. Grep the rest of the codebase for other @PostConstruct methods that only assign a field inside an if branch keyed off an @Optional field, without an else — this exact shape is easy to reproduce accidentally in any model with an optional dialog setting and a derived collection.
  4. Consider whether @Optional fields 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 @PostConstruct to 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.