N
Naveenr.dev
Chapter rw-17
10 min read2026-07-23
📖 AEM - Adobe Experience Manager SeriesChapter rw-17 · 14 chapters

Real-World Scenario — The Nav Menu Item That Quietly Disappeared

A real production bug, worked end to end — a global header built from a Content Fragment referencing other Content Fragments went live missing an entire navigation item after an unrelated content move, with no error anywhere. Full working fix that tells "not configured" apart from "broken reference" and surfaces the difference to authors instead of swallowing it silently.

Part of the Real-World AEM Problems series — practical, production issues and how they were solved. Background reading: Fragment Reference vs Content Reference explains the relationship types behind this implementation.

Problem Statement

A site's global header was built from a single top-level Content Fragment holding the overall structure — logo, a set of navigation menu references, a search config reference, an "important safety information" link reference — where several of those fields were themselves paths to other Content Fragments, resolved and adapted into their own Sling Models at render time. This composed-fragment pattern let content authors manage each piece (a nav menu, a logo, a legal link) independently and reuse them across multiple header variants.

Weeks after launch, a DAM cleanup project moved a batch of "legacy" fragments into an archive folder as part of unrelated housekeeping — including one that happened to be referenced by the header's navigation list. The header kept rendering. Nothing errored, nothing logged. It just quietly rendered with one fewer navigation item than it should have had, because the code resolving that reference was written defensively: a reference that doesn't resolve returns nothing, exactly the same as a reference that was simply left blank by design (not every header variant used every optional slot).

Nobody noticed until a stakeholder asked why a menu item was missing on a specific site variant, days after the DAM cleanup had happened, with no way to correlate the two events without cross-referencing DAM audit logs against a UI screenshot. There was no way to distinguish, in the code or in any log, between "that slot was never supposed to have anything in it" and "that slot's reference is now broken" — both looked identical: an empty result, resolved silently, at every single request since the fragment moved.

Approach and Why

The defensive design itself wasn't wrong — a broken sub-fragment reference genuinely should never take down the entire header for every visitor on every page. The gap was that "resolution failed" and "nothing was configured" collapsed into the exact same outcome, throwing away the one distinction that would have let anyone catch this quickly.

Make the resolver return a result that distinguishes why nothing came back, not just whether something came back. Instead of a plain nullable/empty return, resolution needed three genuinely different outcomes: not configured (the path itself is blank — expected and fine), broken reference (a path is set, but the resource it points to doesn't exist or won't adapt — not fine, and worth knowing about), and resolved (got a usable model). Collapsing all three into "got something or didn't" is exactly what made this invisible.

Keep rendering defensive regardless of which outcome occurred. The fix doesn't change render-time behavior — a broken reference still renders as if that slot were empty, because that's still the least-bad outcome for a live visitor. What changes is that the broken case gets recorded as it happens, separately from the rendering path.

Surface the diagnostic only where authors and developers will actually see it — not in visitor-facing output, and not buried in a log stream nobody's watching. The simplest version of this doesn't need a monitoring pipeline: attaching the list of broken references to the model itself, and rendering a small, author-mode-only marker when the page is being edited or previewed, means the person who can actually do something about it (an author checking a page, or a developer investigating a report) sees "2 broken fragment references" immediately, instead of a visitor silently getting a slightly wrong header and nobody knowing until someone happens to notice.

POC

java
@ExtendWith(AemContextExtension.class)
class FragmentReferenceResolverTest {

    @Test
    void blankPathIsNotConfiguredNotBroken() {
        ResolutionResult<CfmLinkMenuModel> result =
                FragmentReferenceResolver.resolve(context.resourceResolver(), "", CfmLinkMenuModel.class);

        assertThat(result).isInstanceOf(ResolutionResult.NotConfigured.class);
    }

    @Test
    void pathSetButResourceMissingIsReportedAsBrokenReference() {
        ResolutionResult<CfmLinkMenuModel> result = FragmentReferenceResolver.resolve(
                context.resourceResolver(), "/content/dam/fragments/moved-away", CfmLinkMenuModel.class);

        assertThat(result).isInstanceOf(ResolutionResult.BrokenReference.class);
        assertThat(((ResolutionResult.BrokenReference<?>) result).path()).isEqualTo("/content/dam/fragments/moved-away");
    }

    @Test
    void validPathResolvesToModel() {
        context.create().resource("/content/dam/fragments/promo/jcr:content/data/master",
                Map.of("linkText", "Shop Now"));
        // resourceType left as default sling:Folder-like plain resource is enough for an adaptTo stub in this POC

        ResolutionResult<StubModel> result =
                FragmentReferenceResolver.resolve(context.resourceResolver(), "/content/dam/fragments/promo", StubModel.class);

        assertThat(result).isInstanceOf(ResolutionResult.Resolved.class);
    }

    @Test
    void headerModelCollectsBrokenReferencesSeparatelyFromRenderedFields() {
        // one nav path blank (not configured), one nav path pointing at a moved/deleted fragment (broken)
        context.request().setAttribute("headerCfPath", "/content/dam/fragments/header");
        context.create().resource("/content/dam/fragments/header/jcr:content/data/master",
                Map.of("navigation", new String[] { "", "/content/dam/fragments/archived-nav-item" }));

        SiteHeaderModel header = context.request().adaptTo(SiteHeaderModel.class);

        assertThat(header.getNavigationItems()).isEmpty(); // still renders safely — no NPE, no partial menu
        assertThat(header.getAuthoringDiagnostics()).hasSize(1); // but the broken one is now visible to authors
    }
}

The fourth test is the one that matters most — it proves the two failure modes stay separate all the way up to the model an author-mode overlay would read from, not just inside the low-level resolver.

Implementation

The sealed result type below requires Java 17 or later. On an older AEM runtime, use a conventional interface with concrete result classes; the three outcomes remain the same.

java
package com.example.aem.core.contentfragments;

/**
 * Result of resolving a Content Fragment sub-reference to a Sling Model.
 * Distinguishes "nothing configured" from "configured but broken" — the
 * distinction a plain null/empty return can't make.
 */
public sealed interface ResolutionResult<T> {

    record NotConfigured<T>() implements ResolutionResult<T> {
    }

    record BrokenReference<T>(String path, String reason) implements ResolutionResult<T> {
    }

    record Resolved<T>(T model) implements ResolutionResult<T> {
    }
}
java
package com.example.aem.core.contentfragments;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;

/**
 * Resolves Content Fragment sub-references (a path stored as a field on one
 * fragment, pointing at another fragment) to Sling Models, reporting *why*
 * a reference produced nothing rather than collapsing every failure mode
 * into a plain empty result.
 */
public final class FragmentReferenceResolver {

    private static final String MASTER_VARIATION_PATH = "/jcr:content/data/master";

    private FragmentReferenceResolver() {
    }

    public static <T> ResolutionResult<T> resolve(ResourceResolver resolver, String path, Class<T> type) {
        if (StringUtils.isBlank(path)) {
            return new ResolutionResult.NotConfigured<>();
        }

        Resource resource = resolver.getResource(path + MASTER_VARIATION_PATH);
        if (resource == null) {
            return new ResolutionResult.BrokenReference<>(path, "referenced fragment no longer exists at this path");
        }

        T model = resource.adaptTo(type);
        if (model == null) {
            return new ResolutionResult.BrokenReference<>(path, "resource exists but could not adapt to " + type.getSimpleName());
        }

        return new ResolutionResult.Resolved<>(model);
    }
}
java
package com.example.aem.core.contentfragments;

/** A single reference that was configured but failed to resolve, surfaced to authors/developers. */
public record BrokenFragmentReference(String fieldName, String path, String reason) {

    public String describe() {
        return fieldName + " -> " + path + " (" + reason + ")";
    }
}
java
package com.example.aem.core.models;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import javax.annotation.PostConstruct;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.SlingObject;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;

import com.adobe.cq.dam.cfm.ContentElement;
import com.adobe.cq.dam.cfm.ContentFragment;
import com.example.aem.core.contentfragments.BrokenFragmentReference;
import com.example.aem.core.contentfragments.FragmentReferenceResolver;
import com.example.aem.core.contentfragments.ResolutionResult;

/**
 * Global header, composed from a top-level Content Fragment whose fields
 * reference other fragments (navigation items, logo, ISI link). Broken
 * sub-fragment references still render safely as absent, but are also
 * collected so an author-mode overlay can flag them instead of nobody
 * finding out until a visitor notices a missing menu item.
 */
@Model(adaptables = { Resource.class, SlingHttpServletRequest.class },
        resourceType = "example/components/header", defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class SiteHeaderModel {

    @ValueMapValue
    private String headerCfPath;

    @SlingObject
    private ResourceResolver resourceResolver;

    private List<CfmLinkMenuModel> navigationItems = Collections.emptyList();
    private CfmLinkModel importantSafetyInformationLink;
    private final List<BrokenFragmentReference> authoringDiagnostics = new ArrayList<>();

    @PostConstruct
    protected void init() {
        if (StringUtils.isBlank(headerCfPath) || resourceResolver == null) {
            return;
        }

        Resource cfResource = resourceResolver.getResource(headerCfPath);
        ContentFragment cf = cfResource != null ? cfResource.adaptTo(ContentFragment.class) : null;
        if (cf == null) {
            return;
        }

        navigationItems = resolveNavigationItems(cf);
        importantSafetyInformationLink = resolveSingle(cf, "isiLink", CfmLinkModel.class);
    }

    private List<CfmLinkMenuModel> resolveNavigationItems(ContentFragment cf) {
        List<String> paths = readReferencePaths(cf, "navigation");
        List<CfmLinkMenuModel> resolved = new ArrayList<>();

        for (String path : paths) {
            ResolutionResult<CfmLinkMenuModel> result =
                    FragmentReferenceResolver.resolve(resourceResolver, path, CfmLinkMenuModel.class);
            recordIfBroken("navigation", result);
            if (result instanceof ResolutionResult.Resolved<CfmLinkMenuModel> resolvedResult) {
                resolved.add(resolvedResult.model());
            }
        }
        return Collections.unmodifiableList(resolved);
    }

    private <T> T resolveSingle(ContentFragment cf, String fieldName, Class<T> type) {
        String path = readSingleReferencePath(cf, fieldName);
        ResolutionResult<T> result = FragmentReferenceResolver.resolve(resourceResolver, path, type);
        recordIfBroken(fieldName, result);
        return result instanceof ResolutionResult.Resolved<T> resolved ? resolved.model() : null;
    }

    private <T> void recordIfBroken(String fieldName, ResolutionResult<T> result) {
        if (result instanceof ResolutionResult.BrokenReference<T> broken) {
            authoringDiagnostics.add(new BrokenFragmentReference(fieldName, broken.path(), broken.reason()));
        }
    }

    private List<String> readReferencePaths(ContentFragment cf, String elementName) {
        ContentElement el = cf.getElement(elementName);
        if (el == null || el.getValue() == null || el.getValue().getValue() == null) {
            return Collections.emptyList();
        }
        Object value = el.getValue().getValue();
        if (value instanceof String[] paths) {
            return List.of(paths);
        }
        if (value instanceof String path && StringUtils.isNotBlank(path)) {
            return List.of(path);
        }
        return Collections.emptyList();
    }

    private String readSingleReferencePath(ContentFragment cf, String elementName) {
        List<String> paths = readReferencePaths(cf, elementName);
        return paths.isEmpty() ? StringUtils.EMPTY : paths.get(0);
    }

    public List<CfmLinkMenuModel> getNavigationItems() {
        return navigationItems;
    }

    public CfmLinkModel getImportantSafetyInformationLink() {
        return importantSafetyInformationLink;
    }

    /** Empty for a healthy page. Non-empty means something referenced here no longer resolves. */
    public List<BrokenFragmentReference> getAuthoringDiagnostics() {
        return Collections.unmodifiableList(authoringDiagnostics);
    }
}
html
<!-- header.html — the author-mode-only diagnostic marker -->
<div data-sly-use.model="com.example.aem.core.models.SiteHeaderModel">
    <header class="site-header">
        <!-- normal header markup using model.navigationItems, model.importantSafetyInformationLink, etc. -->
    </header>

    <div data-sly-test="${wcmmode.edit && model.authoringDiagnostics.size > 0}" class="cf-diagnostics-banner">
        <strong>${model.authoringDiagnostics.size} broken Content Fragment reference(s) in this header:</strong>
        <ul data-sly-list.entry="${model.authoringDiagnostics}">
            <li>${entry.fieldName} → ${entry.path} (${entry.reason})</li>
        </ul>
    </div>
</div>

Rollout Steps

  1. Introduced FragmentReferenceResolver and ResolutionResult alongside the existing resolution helper, without touching any model yet — confirmed the three-outcome behavior against known-good, known-blank, and known-broken paths in isolation first.
  2. Migrated the header model first, since it was the one with the actual incident, and manually re-created the original broken-reference scenario in a lower environment (moved a referenced fragment out from under a live header) to confirm the author-mode banner actually appeared where the missing menu item used to be.
  3. Rolled the same resolver out to the remaining Content-Fragment-composed models (footer, mini cart, profile menu) over the following days, replacing their direct fragment-reading calls one model at a time rather than as one large change.
  4. Asked the content team to spot-check a few pages in preview after a routine DAM reorganization the following month — the banner caught two more genuinely broken references before any visitor-facing report came in, which was the actual validation that the fix worked as intended.

Why This Approach Held Up

The fix didn't try to prevent editors from moving or archiving fragments — that's normal content operations and will keep happening. It made the one previously invisible failure mode visible to exactly the people who could act on it, at exactly the point they're already looking (editing or previewing the page), without changing what a visitor sees on a broken reference.

Because the diagnostic collection lives in the model itself rather than in a separate monitoring system, it required no new infrastructure and showed up immediately for every page using the affected components — including the older pages from before the DAM cleanup that nobody had thought to re-check.

What's Next

This is one entry in an ongoing series of real production AEM problems and how they were actually solved. Check back for the next one.

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.