N
Naveenr.dev
Chapter 103
12 min read2026-08-28

Inherited Page Properties and Site-Wide Feature Toggles

How Sling's HierarchyNodeInheritanceValueMap lets a page property cascade down from a site root unless a descendant page overrides it, why "not set" and "set to false" produce the same visible result, and why that makes changing a toggle's default a much bigger change than it looks.

Most page properties in AEM are set once, on one page, and read on that same page. Some properties don't work that way at all — a site-wide setting like "hide the back-to-top button" or "enable this third-party cookie script" gets set once near the root of a site and is expected to apply to every descendant page automatically, unless a specific page explicitly overrides it. Sling has a built-in mechanism for exactly this, and it's used far more than its one-line usage in code would suggest.

Problem

A large site tree has hundreds of pages under a handful of section roots. A handful of properties — feature toggles, third-party script IDs, site name, GTM container ID — genuinely belong at the site-root or section-root level and should apply to every page underneath without every single page author having to set them individually. But some pages legitimately need to override the inherited value: a single campaign page might need a different GTM ID, or a single subsection might need the back-to-top button re-enabled even though the site root disabled it.

Reading a property "the normal way" (resource.getValueMap().get(name)) only looks at the current page — it has no concept of walking up to a parent to find a value. Something else is needed that walks the content tree, checking each ancestor's jcr:content node in turn, stopping at the first one that actually has the property set.

Architecture

Sling ships exactly this as org.apache.sling.api.resource.InheritanceValueMap, with HierarchyNodeInheritanceValueMap as the standard implementation used in page-property inheritance. The core contract:

java
InheritanceValueMap inheritedProperties = new HierarchyNodeInheritanceValueMap(resource);
String value = inheritedProperties.getInherited(propertyName, defaultValue);

getInherited walks upward starting at the given resource: check this resource's own properties for propertyName; if present, return it immediately (no further walking); if absent, move to the parent's jcr:content node and repeat; if the walk reaches the top of the tree with no page ever having set the property, return the caller-supplied default.

The subtlety that matters: "present" means the property key exists at all on some page in the chain, not that it holds any particular value. A page that explicitly sets a boolean toggle to false is treated identically, at the API level, to a page where the toggle is simply unset and the default happens to be false — both produce false from getInherited. The map has no way to tell you which of those two things actually happened.

Repository

A production page model wiring roughly a dozen properties through this pattern (from an AEM 6.5 site's page Sling Model, property and constant names generalized):

java
@Model(adaptables = { SlingHttpServletRequest.class }, adapters = PageModel.class)
public class SitePageImpl implements PageModel {

    private static final String ENABLE_RIGHT_TO_LEFT = "rightToLeft";
    private static final String HIDE_BACK_TO_TOP = "hideBackToTopFromChildPages";
    private static final String ANALYTICS_COOKIES_ENABLED = "analyticsCookiesEnabled";
    private static final String GTM_CONTAINER_ID = "gtmId";

    private String rightToLeft;
    private boolean hideBackToTop;
    private boolean analyticsCookiesEnabled;
    private String gtmContainerId;

    @PostConstruct
    protected void init() {
        rightToLeft = setInheritedPageValues(ENABLE_RIGHT_TO_LEFT, resource, "");
        hideBackToTop = setInheritedBooleanPageValues(HIDE_BACK_TO_TOP, resource);
        analyticsCookiesEnabled = setInheritedBooleanPageValues(ANALYTICS_COOKIES_ENABLED, resource);
        gtmContainerId = setInheritedPageValues(GTM_CONTAINER_ID, resource);
    }

    protected String setInheritedPageValues(String name, Resource resource) {
        return setInheritedPageValues(name, resource, null);
    }

    private String setInheritedPageValues(String name, Resource resource, String defaultValue) {
        InheritanceValueMap inheritedProperties = new HierarchyNodeInheritanceValueMap(resource);
        return inheritedProperties.getInherited(name, defaultValue);
    }

    private boolean setInheritedBooleanPageValues(String propName, Resource resource) {
        InheritanceValueMap inheritedProperties = new HierarchyNodeInheritanceValueMap(resource);
        return inheritedProperties.getInherited(propName, false);
    }
}

Notice each call site chooses its own default inline — "" for one property, null for another (via the two-arg overload), false for boolean toggles. There's no single place that documents "here is every inheritable property on this site and its intended default"; that knowledge is scattered across whichever line of code happens to call getInherited for that property.

How It Works

Because a page's jcr:content node is a plain resource, HierarchyNodeInheritanceValueMap doesn't need any special page API to walk the tree — it's just resource-to-parent-resource traversal, checking jcr:content at each level, which is why it works uniformly whether the resource passed in is a page's own content resource or (less usefully) some non-page resource nested under one.

The practical mental model for an author or developer setting one of these properties: setting it explicitly on a page — to true or false — always wins over whatever any ancestor has, because the walk stops at the first resource with the key present, and it starts at the resource you gave it. Not setting it at all means "keep walking up," all the way to the root, and if nothing up the whole chain ever sets it, the hardcoded default in the Java code decides the value.

Real Project Example

A site had analyticsCookiesEnabled unset on essentially every page — nobody had ever explicitly toggled it anywhere, because the feature had shipped with a hardcoded default of false and nobody needed it on. Eighteen months later, a new requirement meant most of the site should have analytics cookies enabled by default, with only a couple of legacy pages needing them off. The natural-looking fix was a one-line change: flip the hardcoded default passed to getInherited from false to true.

That single-line change flipped the effective value on every page in the tree that had never explicitly set the property — which, since nobody had ever needed to override the old default, was nearly the entire site. It also flipped a small number of pages where a previous team member genuinely had set the property to false on purpose for a compliance reason unrelated to this rollout, except those pages were unaffected by the default change (their explicit false still won), which made the rollout's actual blast radius hard to reason about without literally checking every page's own content node — the code has no way to answer "which pages are relying on the default versus explicitly disabled" except by inspecting jcr:content directly. The eventual fix was to explicitly set the property at a small number of section roots that needed the old (disabled) behavior, rather than trying to distinguish "explicit false" from "defaulted false" after the fact — because that distinction genuinely doesn't exist in the stored content once both produce the same boolean.

Production Troubleshooting

  • getInherited cannot tell you whether a value came from an explicit setting on this page, an ancestor's explicit setting, or the fallback default. If you need that distinction (for an admin UI showing "inherited vs overridden," for instance), you have to check resource.getValueMap().get(name) (this page only, no inheritance) separately from the inherited call, and compare.
  • Changing a hardcoded default in code is a content-tree-wide behavior change, not a code-only change. Treat it with the same caution as a data migration — audit which pages currently rely on the default before flipping it, the same way you would before changing a database column's default value.
  • The resource passed to HierarchyNodeInheritanceValueMap needs to be on a real page hierarchy for the walk to reach meaningful ancestors — passing a resource from deep inside a component tree (rather than the page's own content resource) walks up through component/parsys ancestors first, which usually never have the property, before eventually reaching page-level ancestors; harmless but wasteful, and confusing if someone assumes it walks page-to-page directly.
  • Every call site choosing its own default independently means the actual site-wide behavior of a toggle is defined by whichever engineer wrote that one getInherited(name, someDefault) line — a central registry or constants class mapping property name to intended default is worth the extra indirection once there are more than a handful of these.

Why Architects Care

Inheritance-based page properties are one of the few places in AEM where a single Java constant's default value can silently change behavior across an entire site tree, because the property doesn't need to exist anywhere in content for the default to apply everywhere. That makes reviewing a change to one of these defaults fundamentally different from reviewing a normal code change — the actual diff that matters isn't the one line in Java, it's every page in the repository that has never set the property explicitly, and that list isn't visible in the pull request at all.

Summary

  • HierarchyNodeInheritanceValueMap.getInherited(name, defaultValue) walks from a resource up through ancestor jcr:content nodes, returning the first explicitly-set value found, or the caller's default if nothing in the chain ever set it.
  • An explicit value at any level — including an explicit false — always wins over inheritance from further up; only a genuinely unset property continues the walk.
  • The map cannot distinguish "explicitly set to the same value as the default" from "never set, using the default" — both look identical from the caller's side.
  • Changing a hardcoded default is effectively a site-wide content behavior change and should be audited (which pages currently rely on it) before it ships, not treated as a routine one-line code edit.

What's Next

The companion real-world post walks through exactly this "flip the default" rollout going wrong on a real toggle, and the audit query that should have run before the change shipped.

Want to See This Applied to a Real Problem?

See Flipping a Feature Toggle's Default Changed More Pages Than Expected for the full incident and the pre-rollout audit script that would have caught the blast radius in advance.

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.