For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/advanced/content-fragments.md.

Content Fragments

15 – Content Fragments

Target: 3–4 years experienced AEM Developer Syllabus points covered (24 partly, 25 in full): Point 24 — "CF and XF, and the difference between them." (Content Fragments here; Experience Fragments and the full comparison in file 16.) Point 25 — "How do you read a CF in a model class?" Project domain: a global energy technology company's marketing site.


Before we start — the sentence that separates CF from XF

Your syllabus asks for the difference between Content Fragments and Experience Fragments, and there is one sentence that gets you most of the way:

A Content Fragment is content without presentation. An Experience Fragment is presentation with content.

Everything else follows. A Content Fragment holds what you want to say — a product name, a specification, a description — with no opinion about how it looks. An Experience Fragment holds a piece of a page — actual components, laid out and styled.

Which means they answer different questions:

  • "I need this same information on the website, in the mobile app, and in a partner's system." → Content Fragment
  • "I need this same banner on twenty pages." → Experience Fragment

Get that distinction crisp and the rest of both files is detail.

One thing worth flagging early, because it surprises people and comes up: Content Fragments are stored in the DAM, under /content/dam, as assets. They are not pages and they are not under /content. That trips candidates up, and knowing it is a small proof you have actually worked with them.


1. Introduction

1.1 The problem Content Fragments solve

Our energy site has a product page for each transformer, with technical specifications — voltage range, cooling type, standards compliance, weight.

Now the mobile app needs the same specifications. And a partner integration wants them as JSON. And the specifications also appear in a comparison table on a different page.

Without Content Fragments, those specifications live inside a component on the product page — as dialog values on a jcr:content node somewhere. Which means they are presentation-bound. The mobile app cannot easily consume them, because they are entangled with the page that renders them.

A Content Fragment separates the content from anywhere it happens to be displayed. The specification exists as a piece of structured content in its own right. The web page renders it. The mobile app fetches it as JSON. The comparison table references it. One source, many consumers.

That is what "channel-agnostic" and "headless" actually mean in practice, and it is the framing to use.

1.2 Where they live, and why that matters

/content/dam/energy/fragments/products/transformer-tx-4000

Content Fragments are assets in the DAM. Not pages, not under /content/<site>.

Why that's the right design: a fragment is a piece of content that belongs to no particular page. Putting it under a site's content tree would imply it belongs to that site. The DAM is where reusable, non-page content lives — alongside images and documents, which have exactly the same property.

Two practical consequences:

DAM permissions apply. Authors who can create fragments need DAM access, not just site access (file 13). And on publish, anonymous needs read on the fragment path or a component renders nothing.

They're activated like assets. Publishing a page that references a fragment does not publish the fragment. That is a genuinely common "works on author, blank on publish" cause, and it's the same family of problem as file 13's.

1.3 A real project example to adapt

"We use Content Fragments for product specifications and technical articles — anything that needs to exist independently of a page. The specifications for a transformer are a fragment, modelled with typed fields for voltage range, cooling type and standards compliance, and they're consumed three ways: rendered on the product page by a component, pulled into a comparison table on a different page, and delivered to the mobile app as JSON through GraphQL persisted queries.

The reason for persisted queries rather than ad-hoc GraphQL is caching — a persisted query is a GET on a stable path, so the dispatcher and CDN cache it like any other response. An ad-hoc POST query isn't cacheable at all, which for an app hitting it on every launch would have meant every request reaching publish."

That covers the use case, the model, the three consumers, and the caching decision — which is the architecturally interesting part.


2. Core Concepts

2.1 Content Fragment Models

A model defines the structure. It is the schema — what fields a fragment of this kind has, and what type each one is.

Where models live:

/conf/<project>/settings/dam/cfm/models/<model-name>

Note that's /conf — the same place as templates and policies from file 03. Models are configuration, per-site, and they ship in ui.content as mutable content.

The field types available:

TypeFor
Single line textNames, short values
Multi line textBody content — plain text, rich text, or markdown
NumberNumeric values
BooleanFlags
Date and timeDates
EnumerationA fixed set of options
TagsTaxonomy references
Content referenceA path to another asset or page
Fragment referenceA reference to another Content Fragment
JSON objectArbitrary structured data

Two worth calling out:

Fragment reference is what lets you compose fragments — a product fragment referencing a manufacturer fragment, for instance. That's how you avoid duplicating shared data.

Multi line text has a content type, which matters for rendering. It can be plain text, rich text (HTML) or markdown, and your code has to know which — section 2.6.

A model must be enabled before fragments can be created from it. A disabled model simply doesn't appear in the create dialog, which is the same shape as the status="draft" problem with templates in file 03.

2.2 The repository structure of a fragment

Worth knowing, because it explains both the API and a shortcut people take.

/content/dam/energy/fragments/products/transformer-tx-4000    (dam:Asset)
└── jcr:content                                               (dam:AssetContent)
    ├── data
    │   ├── cq:model = "/conf/energy/settings/dam/cfm/models/product"
    │   ├── master                          ← the MASTER variation
    │   │   ├── productName = "TX-4000"
    │   │   ├── voltageRange = "72.5 kV"
    │   │   └── description = "<p>...</p>"
    │   └── social                          ← another variation
    │       └── description = "<p>shorter...</p>"
    └── metadata

Three things to notice:

cq:model points at the Content Fragment Model. That's the link between fragment and schema.

master is a variation, not a special container — it's simply the default one. Other variations sit beside it.

Element values live under a variation, which is why the API has both element and variation concepts.

2.3 Variations

A variation is an alternative version of the fragment's content, for a different channel or context.

The canonical example: a description that's three paragraphs on the website, one sentence for a social media card, and a two-line summary for a listing. Same fragment, same underlying content, different renderings of the text itself — not different styling.

That last distinction matters. A variation is not "the same content styled differently" — that would be presentation, and presentation isn't a Content Fragment's job. A variation is genuinely different wording for a different context.

master is the default. If you ask for a variation that doesn't exist, you fall back to master.

A caution worth raising: variations are easy to over-use. Every variation is content someone has to write and maintain, and they drift. If the difference is purely how it's displayed, that belongs in the rendering, not in a variation.

2.4 Reading a Content Fragment in a Sling Model (syllabus point 25)

Your syllabus asks this directly. Here's the proper answer.

The API is com.adobe.cq.dam.cfm.ContentFragment, and you get there by adapting the fragment's resource:

Resource fragmentResource = resourceResolver.getResource(fragmentPath);
ContentFragment fragment = fragmentResource.adaptTo(ContentFragment.class);

Note you adapt the asset node itself — /content/dam/.../transformer-tx-4000 — not its jcr:content.

And as always, adaptTo can return null (file 05). If the path is wrong, or the resource isn't a Content Fragment, you get null rather than an exception.

The three classes to know:

ClassWhat it is
ContentFragmentThe fragment
ContentElementOne field
FragmentDataThe typed value of an element

Reading a field:

ContentElement element = fragment.getElement("productName");
if (element != null) {
    String value = element.getContent();
}

Reading it typed, which matters for numbers, booleans and dates:

FragmentData data = element.getValue();
String contentType = data.getContentType();      // "text/html", "text/plain", …
Object value = data.getValue();
String asString = data.getValue(String.class);

Reading a variation:

ContentVariation variation = element.getVariation("social");
String value = (variation != null)
        ? variation.getContent()
        : element.getContent();      // fall back to master

Iterating the elements, when you don't know the model in advance:

Iterator<ContentElement> elements = fragment.getElements();

Fragment metadata:

fragment.getName();
fragment.getTitle();
fragment.getDescription();

The full worked example is in section 8, but the interview answer is:

"I get the fragment's resource by path and adapt it to ContentFragment — and I adapt the asset node itself, not its jcr:content. Then getElement(name) gives me a ContentElement, and getContent() gives me the value, or getValue() gives me a FragmentData if I need the type. Variations come from element.getVariation(name), falling back to master when it doesn't exist.

Two things I'm careful about. adaptTo returns null if the path is wrong or the resource isn't a fragment, so it's always null-checked — and I'd do that work in @PostConstruct rather than in a getter, because HTL can call getters repeatedly.

And for multi-line text I check the content type, because a field can be plain text, rich text or markdown. If it's text/html I render it with context='html' in HTL; if I use the default escaping the author sees literal tags on the page."

2.5 The shortcut, and why to avoid it

Because the repository structure is knowable, you can read a fragment with plain Sling Model injections:

// This works. Don't do it.
@ValueMapValue
@Via("resource")
private String productName;   // reading jcr:content/data/master/productName

Why not:

It's fragile. You're depending on the internal storage layout, which the API exists to insulate you from.

You lose the type information. No content type, so you can't tell rich text from plain.

Variations become manual. You'd hand-code the fallback to master.

And it breaks if the model changes shape.

Being able to say "you can, but I wouldn't, and here's why" is a better answer than either just knowing the API or just knowing the shortcut.

2.6 Content types on multi-line text — the rendering trap

A multi-line text element carries a content type:

Content typeMeans
text/plainPlain text
text/htmlRich text — contains markup
text/x-markdownMarkdown

Why this matters connects directly to file 08. If the element is text/html and you render it with HTL's default escaping, the visitor sees literal <p> and <strong> tags. If it's text/plain and you render it with context='html', you've created an injection point.

So the model should expose the content type, and the HTL should branch on it — or the model should normalise it. Section 8 shows both.

This is the same bug as story 1 in file 11, arriving from a different direction, which is worth noticing: rich text is rich text wherever it comes from.

2.7 GraphQL — the headless delivery story

This is what makes Content Fragments architecturally interesting, and it's where the interview usually goes next.

Content Fragment Models automatically generate a GraphQL schema. Create a product model and you get query types for it — fetch one by path, or list them with filters. You write no schema code.

Two ways to query, and the difference is the whole point:

Ad-hoc queryPersisted query
HowPOST to the GraphQL endpointQuery stored server-side, called by GET
URLThe endpoint/graphql/execute.json/<config>/<queryName>
CacheableNoYes
Good forDevelopment, explorationProduction

Why persisted queries are the production answer — and this is the same reasoning as files 01, 04 and 07:

A POST to a single endpoint isn't cacheable. Every request reaches publish. A persisted query is a GET on a stable, path-based URL, so the dispatcher caches it as a file and the CDN caches it at the edge.

For a mobile app fetching product data on every launch, that's the difference between every launch hitting publish and almost none of them doing so.

A second benefit worth mentioning: persisted queries mean the client can't send arbitrary queries. An ad-hoc GraphQL endpoint lets a caller construct expensive or over-broad queries; persisted queries constrain them to ones you've approved. That's a security and performance property, not just a caching one.

The interview answer:

"Content Fragment Models generate a GraphQL schema automatically, so you get queries per model without writing any schema code.

The important decision is persisted queries versus ad-hoc. An ad-hoc query is a POST to the endpoint, which isn't cacheable — every request goes through to publish. A persisted query is stored server-side and called with a GET on a stable path, so the dispatcher and CDN cache it like any other response. For an app hitting it on every launch that's the difference between all the traffic reaching publish and almost none of it.

There's a second reason too: persisted queries mean callers can only run queries we've approved, rather than constructing arbitrary ones that might be expensive or expose more than intended."

2.8 When to use a Content Fragment — and when not to

Use one when:

  • The content is consumed by more than one channel — web, app, partner system.
  • It's structured — distinct typed fields, not free-form layout.
  • It has its own lifecycle — created, reviewed and updated independently of any page.
  • The same content appears in several different renderings.

Don't use one when:

  • The content only ever appears in one place, in one way. A component dialog is simpler and authors find it easier.
  • What you actually want is layout reuse — that's an Experience Fragment (file 16).
  • You'd be putting HTML markup into a single multi-line field, which defeats the purpose.

That last one is worth stating, because it's a genuine anti-pattern: a fragment with one rich-text field containing a whole page's worth of markup is not structured content. It's a page in the wrong place, and none of the headless benefits apply because a consumer can't do anything with an opaque blob of HTML.


3. Internal Working

3.1 How a fragment reaches a page

graph TD A["Content Fragment Model<br/>/conf/.../dam/cfm/models/product"] -->|"defines the structure"| B["Content Fragment<br/>/content/dam/.../transformer-tx-4000"] B -->|"referenced by path"| C["A component on the page"] C -->|"adaptTo(ContentFragment.class)"| D["Sling Model reads elements"] D --> E["HTL renders it"] B -->|"GraphQL persisted query"| F["Mobile app / partner<br/>gets JSON"]

The key structural point: the fragment is referenced by path, from a component. It has no knowledge of where it's used, which is exactly what makes it reusable — and also why deleting or moving one can silently break pages that reference it.

3.2 Why a fragment renders on author but not publish

A recurring theme, arriving here from a new direction:

graph TD A["Fragment renders on author,<br/>blank on publish"] --> B{"Was the FRAGMENT activated?"} B -->|No| C["Publishing the PAGE does NOT<br/>publish the fragment.<br/>They are separate assets."] B -->|Yes| D{"Can anonymous read<br/>/content/dam/...?"} D -->|No| E["DAM permissions — file 13"] D -->|Yes| F{"Is the reference path correct<br/>on this environment?"} F -->|No| G["adaptTo returned null,<br/>and the model rendered nothing"]

The first branch is the common one and it's worth stating clearly: a Content Fragment is a separate asset with its own publication state. Activating the page that references it does not activate it. That's not a bug — the fragment might be referenced by fifty pages — but it surprises people every time.

3.3 Model changes and existing fragments

A practical concern that gets asked:

Change to the modelEffect on existing fragments
Add a fieldSafe — existing fragments simply have no value for it
Rename a fieldBreaks — the stored property name no longer matches
Remove a fieldData remains in the repository but is no longer surfaced
Change a field's typeRisky — existing values may not convert

The point to make: a Content Fragment Model is much harder to change than a component dialog, because fragments are content that already exists and there's no dialog-save to migrate it. So model design deserves more care up front than a dialog does.


8. Coding Examples

8.1 Reading a fragment properly

package com.energy.core.models;

import com.adobe.cq.dam.cfm.ContentElement;
import com.adobe.cq.dam.cfm.ContentFragment;
import com.adobe.cq.dam.cfm.ContentVariation;
import com.adobe.cq.dam.cfm.FragmentData;
import org.apache.commons.lang3.StringUtils;
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.PostConstruct;

@Model(adaptables = Resource.class,
       defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class ProductSpecificationModel {

    private static final Logger LOG =
            LoggerFactory.getLogger(ProductSpecificationModel.class);

    private static final String HTML_CONTENT_TYPE = "text/html";

    /** The dialog holds a PATH to the fragment in the DAM. */
    @ValueMapValue
    private String fragmentPath;

    /** Optional: which variation to render. Empty means master. */
    @ValueMapValue
    private String variationName;

    @SlingObject
    private ResourceResolver resourceResolver;

    private ContentFragment fragment;

    // Derived once, in @PostConstruct
    private String productName;
    private String description;
    private boolean descriptionIsHtml;
    private String voltageRange;

    @PostConstruct
    protected void init() {
        // ALL the work happens ONCE here. HTL can call getters many
        // times, and each of these is a repository read (file 05).

        if (StringUtils.isBlank(fragmentPath)) {
            return;
        }

        Resource fragmentResource = resourceResolver.getResource(fragmentPath);
        if (fragmentResource == null) {
            // LOG IT. A missing fragment renders nothing, which is
            // correct behaviour and terrible debugging -- most often
            // this means the fragment was never ACTIVATED, because
            // publishing the page does not publish the fragment.
            LOG.warn("Content fragment not found at {} — is it activated?", fragmentPath);
            return;
        }

        // Adapt the ASSET node, not its jcr:content.
        // adaptTo returns NULL if this isn't a Content Fragment.
        this.fragment = fragmentResource.adaptTo(ContentFragment.class);
        if (fragment == null) {
            LOG.warn("Resource at {} is not a Content Fragment", fragmentPath);
            return;
        }

        this.productName  = readElement("productName");
        this.voltageRange = readElement("voltageRange");

        // Rich text needs its content type, so HTL can escape correctly
        readDescription();
    }

    /**
     * Reads an element, honouring the requested variation and falling
     * back to master when that variation doesn't exist.
     */
    private String readElement(String elementName) {
        ContentElement element = fragment.getElement(elementName);
        if (element == null) {
            // The model may have changed, or the name may be wrong
            return "";
        }

        if (StringUtils.isNotBlank(variationName)) {
            ContentVariation variation = element.getVariation(variationName);
            if (variation != null) {
                return StringUtils.defaultString(variation.getContent());
            }
            // Variation absent → fall back to master. Not an error.
        }

        return StringUtils.defaultString(element.getContent());
    }

    /**
     * Multi-line text carries a CONTENT TYPE -- plain, rich or markdown.
     *
     * It matters for rendering: if it's text/html and we use HTL's
     * default escaping, the visitor sees literal <p> tags. If it's
     * plain text and we use context='html', we've created an injection
     * point. So the model exposes the type rather than the HTL guessing.
     */
    private void readDescription() {
        ContentElement element = fragment.getElement("description");
        if (element == null) {
            this.description = "";
            return;
        }

        FragmentData data = element.getValue();
        if (data != null) {
            this.descriptionIsHtml = HTML_CONTENT_TYPE.equals(data.getContentType());
            String value = data.getValue(String.class);
            this.description = StringUtils.defaultString(value);
        } else {
            this.description = StringUtils.defaultString(element.getContent());
        }
    }

    // ---- getters: trivial, never null ----

    public String getProductName()  { return StringUtils.defaultString(productName); }
    public String getVoltageRange() { return StringUtils.defaultString(voltageRange); }
    public String getDescription()  { return StringUtils.defaultString(description); }

    /** Lets the HTL choose the right escaping context. */
    public boolean isDescriptionHtml() { return descriptionIsHtml; }

    /** Render nothing rather than an empty shell (file 05). */
    public boolean isReady() {
        return fragment != null && StringUtils.isNotBlank(productName);
    }
}

The six decisions to be able to defend:

Everything in @PostConstruct — each element read is a repository operation, and HTL calls getters repeatedly.

Adapt the asset node, not jcr:content.

Null-check adaptTo, and log when the fragment is missing — because silent absence is correct behaviour and useless for debugging.

Variation with a fallback to master, treated as normal rather than an error.

The content type is read and exposed, so the HTL can escape correctly rather than guessing.

Getters never return null (file 05), and isReady() guards the render.

8.2 The HTL

<sly data-sly-use.spec="com.energy.core.models.ProductSpecificationModel"/>

<div class="cmp-product-spec" data-sly-test="${spec.ready}">

    <h2 class="cmp-product-spec__name">${spec.productName}</h2>

    <dl class="cmp-product-spec__values" data-sly-test="${spec.voltageRange}">
        <dt>${'Voltage range' @ i18n}</dt>
        <dd>${spec.voltageRange}</dd>
    </dl>

    <!--/* The model told us the content type, so we escape correctly
           rather than guessing. Rich text needs context='html' or the
           visitor sees literal tags; plain text with context='html'
           would be an injection point (file 08). */-->
    <div class="cmp-product-spec__description" data-sly-test="${spec.description}">
        <sly data-sly-test="${spec.descriptionHtml}">${spec.description @ context='html'}</sly>
        <sly data-sly-test="${!spec.descriptionHtml}">${spec.description}</sly>
    </div>

</div>

<sly data-sly-test="${!spec.ready && wcmmode.edit}"
     data-sly-resource="${'' @ resourceType='wcm/core/components/placeholder'}"/>

8.3 The dialog

<fragmentPath
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
    fieldLabel="Product Specification"
    fieldDescription="Select a product specification fragment"
    name="./fragmentPath"
    required="{Boolean}true"

    <!-- rootPath makes the wrong answer UNREACHABLE rather than
         rejected -- the constrain-don't-validate principle from
         file 11. An  physically cannot pick something outside
         the fragments folder. -->
    rootPath="/content/dam/energy/fragments"

    <!-- Restrict to Content Fragments specifically -->
    filter="hierarchyNotFile"/>

<variationName
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
    fieldLabel="Variation"
    fieldDescription="Leave empty to use the master variation"
    name="./variationName"/>

8.4 Iterating elements when the model isn't known in advance

Useful for a generic renderer — a specifications table that shows whatever fields the fragment has.

@PostConstruct
protected void init() {
    // ... resolve and adapt the fragment ...

    this.rows = new ArrayList<>();

    Iterator<ContentElement> elements = fragment.getElements();
    while (elements.hasNext() && rows.size() < MAX_ROWS) {   // BOUNDED
        ContentElement element = elements.next();

        String value = element.getContent();
        if (StringUtils.isBlank(value)) {
            continue;      // skip empty fields rather than showing blanks
        }

        rows.add(new SpecRow(element.getTitle(), value));
    }
}

Note the bound. Same rule as file 05 — anything building a list gets a ceiling, so a misconfigured or unexpectedly large fragment degrades rather than causing a problem.

And element.getTitle() gives the human-readable label from the model, which is what you want in a table header rather than the field name.

8.5 A persisted GraphQL query

The query, stored server-side:

query getProductByPath($path: String!) {
  productByPath(_path: $path) {
    item {
      productName
      voltageRange
      coolingType
      description {
        html
        plaintext
      }
      standards
    }
  }
}

Called by the app as a GET:

GET /graphql/execute.json/energy/getProductByPath;path=/content/dam/energy/fragments/products/tx-4000

Why this is the production answer:

Ad-hoc:     POST /content/_cq_graphql/energy/endpoint     ← NOT cacheable
Persisted:  GET  /graphql/execute.json/energy/getProduct  ← CACHED as a file

The variables are part of the path, so different products produce different cacheable URLs — the same principle as selectors and suffixes from file 01.

Note also description { html plaintext } — GraphQL exposes both renderings of a multi-line field, so the consumer picks what it needs. That's the structured-content benefit in practice: the app can take plaintext for a summary and html where it renders markup.


9. Common Mistakes

The mistakeWhat happensThe fix
Not activating the fragmentRenders on author, blank on publishFragments are assets — activate them separately
Not logging a missing fragmentSilent absence; nothing to debugLog at warn when the path resolves to nothing
Not null-checking adaptToNullPointerException, or silent nothingAlways check — file 05
Adapting jcr:content instead of the asset nodeReturns nullAdapt the asset node
Reading elements in a getterA repository read per HTL call@PostConstruct
One rich-text field holding a whole articleNot structured content — consumers can't use itModel it with typed fields
Ignoring the content typeLiteral tags rendered, or an injection pointRead getContentType(), branch in HTL
Reading with @ValueMapValue from data/masterFragile; loses type; variations manualUse the ContentFragment API
Renaming a model fieldBreaks existing fragmentsAdd, don't rename
Ad-hoc GraphQL in productionNot cacheable — all traffic reaches publishPersisted queries
No invalidation for cached queriesThe app serves stale contentFlush cached queries on fragment activation
Unbounded element iterationSame risk as any unbounded listA ceiling constant
Deep fragment reference chainsSeveral reads per render; GraphQL fan-outBound the depth in the model design
Using a fragment for single-use contentUnnecessary indirection; authors dislike itA component dialog
No rootPath on the fragment pathfieldAuthors pick the wrong thingConstrain the field (file 11)
Forgetting anonymous DAM read on publishQuery or component returns nothingGrant read on the fragment path (file 13)

10. Best Practices

On modelling. Design the model carefully up front, because it's much harder to change than a dialog — fragments are content that already exists. Use typed fields rather than free text. Never model an entire article as one rich-text field.

On reading. Use the ContentFragment API, not raw property injection. All the work in @PostConstruct. Null-check everything and log when a referenced fragment is missing. Read and expose the content type rather than assuming.

On rendering. Prefer the Core Components Content Fragment component, proxied, unless you genuinely need a different rendering. Constrain the fragment pathfield with rootPath. Guard the render with isReady().

On headless. Persisted queries in production, always. Plan invalidation at the same time — cached query responses go stale when fragments change. Keep the ad-hoc endpoint off on publish.

On operations. Remember fragments publish separately from pages. Check the References panel before changing a widely-used fragment. Grant anonymous read on fragment paths for publish.


11. Debugging Tips

"The fragment renders on author but not publish" — check in this order:

  1. Was the fragment activated? Publishing the page doesn't publish it. This is the most common cause by a wide margin.
  2. Can anonymous read the DAM path? File 13.
  3. Is the path the same on both environments? If not, adaptTo returned null and the model rendered nothing.

"The component renders nothing and there's no error." That's the model working as designed — adaptTo returned null and isReady() is false. Which is exactly why the warn-level log line in section 8.1 matters: it turns silence into a signal.

"Rich text shows literal tags." The element is text/html and the HTL is using default escaping. Check getContentType() and branch (file 08).

"An element returns empty." Either the name doesn't match the model, or the value genuinely isn't set. Look at the fragment in CRXDE under jcr:content/data/master — that tells you immediately whether the data is absent or the read is wrong. Same split-the-problem-in-half move as everywhere else.

"A GraphQL query returns nothing on publish." Usually permissions — anonymous can't read the fragment paths — or the fragments aren't published.

ToolAnswers
CRXDE at jcr:content/data/masterIs the value actually there
The References panel on a fragmentWhich pages use it
Fragment editor → publication statusHas it been activated
GraphiQL (dev environments)Does the query work at all
error.logYour warn line when a fragment is missing
Private window on publish directlyAnonymous behaviour, without the dispatcher

12. Performance Notes

Read once, in @PostConstruct. Each element access is a repository operation, and HTL calls getters repeatedly.

Bound anything iterative. Element iteration, fragment lists, reference chains — a ceiling constant, as everywhere.

Fragment references compound. A fragment referencing another which references another means several reads per render, and in GraphQL a nested query can fan out considerably. That's a model-design decision as much as a code one.

Persisted queries are the headless performance story. Ad-hoc POST queries guarantee every request reaches publish; persisted queries are cached at the dispatcher and CDN. For an app fetching on launch, that's the whole difference.

And plan invalidation with the caching. Cached query responses derived from fragments go stale when fragments change, which is the same problem as the derived listing in file 02.


15. Comparison Tables

Content Fragment vs Experience Fragment (full treatment in file 16)

Content FragmentExperience Fragment
ContainsStructured content, no presentationLaid-out components
Stored/content/dam (as assets)/content/experience-fragments
Based onA Content Fragment Model (/conf)A template
HeadlessYes — GraphQLPartially (plain HTML export)
Reused forSame content, many channelsSame experience, many pages
ExampleProduct specificationsA promo banner, a footer

Field types

TypeNote
Single / multi line textMulti-line carries a content type
Number, boolean, dateTyped
EnumerationFixed options
TagsTaxonomy
Content referenceTo an asset or page
Fragment referenceCompose fragments
JSON objectArbitrary structure

Ad-hoc vs persisted GraphQL

Ad-hocPersisted
MethodPOSTGET
URLThe endpointA stable path
CacheableNoYes
Callers can runAnythingOnly approved queries
Use forDevelopmentProduction

Reading a fragment: API vs raw injection

ContentFragment API@ValueMapValue on data/master
Depends on storage layoutNoYes
Content type availableYesNo
VariationsgetVariation()Manual
Survives model changesBetterFragile
RecommendedYesNo

Model changes

ChangeEffect on existing fragments
Add a fieldSafe
RenameBreaks
RemoveData remains, stops surfacing
Change typeRisky

Next topic

16-Experience-Fragments — the other half of your syllabus point 24: what an Experience Fragment is, how it differs from a Content Fragment in practice, the plain HTML export for third-party channels, and when to use each.


Topic 15 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.