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/resource-resolution-and-sling-resource-merger.md.

Resource Resolution and the Sling Resource Merger

23 – Resource Resolution and the Sling Resource Merger

Target: 3–4 years experienced AEM Developer Companies: Valtech, Publicis Sapient, Deloitte Digital, Cognizant, Accenture, TCS, Capgemini, Infosys, IBM, LTIMindtree Project domain: a global energy technology company's marketing site.


Before we start — why this topic separates the developers from the support engineers

File 01 gave you the headline version of resource resolution: URL → Resource → Resource Type → Script, and a one-line mention that /apps beats /libs. That was enough to survive the architecture round.

This file is what happens when the interviewer doesn't stop there.

Every mid-to-senior AEM interview eventually asks some version of "how do you add one field to a component's dialog without copying the whole thing?" or "why did our short URLs suddenly start rendering /content in every link?" Both questions live entirely inside the machinery this file covers: the ResourceResolver object itself, the exact difference between resolve() and getResource(), how overlay, inheritance and override are three genuinely different mechanisms and not three names for the same trick, how the Sling Resource Merger actually stitches two resource trees together, and how /etc/map and resourceResolver.map() work together — not separately — to give you clean URLs.

Here is why this matters for you specifically. Coming from support, you have almost certainly used the symptoms of this machinery without knowing the mechanism: a ticket where a page 404'd anonymously but worked for a logged-in author, a ticket where "the field doesn't save," a ticket where "links are broken after the URL shortening went live." Every one of those is this file. Learning the mechanism turns four separate memorised incidents into one understood system — and that is exactly the kind of answer that makes an interviewer sit up.


1. Introduction

1.1 What resource resolution actually is, and why it deserves its own file

In file 01 you learned that Sling turns a URL into a resource, and a resource into a script. The object that does all of that work — every single step — is one Java object: the ResourceResolver.

Almost everything you write in AEM eventually touches this object, directly or through something built on top of it. A Sling Model's @ValueMapValue reads from it. A servlet's request.getResourceResolver() is it. A scheduled job that needs to read content has to obtain one. HTL's data-sly-resource asks it for a resource to include. If you don't understand what this object is and whose permissions it carries, half of AEM's stranger behaviours — the field that "doesn't save," the page that 404s for one user and not another, the servlet that silently stops working — will look like bugs in the platform rather than what they actually are: this object behaving exactly as designed.

1.2 The one sentence to memorise before anything else

A ResourceResolver is a session into the repository, and it always carries the permissions of whichever user it was opened for.

That is the entire idea, and everything in this file is a consequence of it. File 13 covers users, groups and ACLs in depth — this file is where those permissions actually get enforced, at the moment a path is turned into a resource.

If the resolver belongs to an anonymous visitor, every getResource() call it makes is filtered by what anonymous can read. If it belongs to a service user with narrow grants, it is filtered by that. If it belongs to an admin, almost nothing is filtered — which is exactly why file 13 tells you never to reach for getAdministrativeResourceResolver() out of convenience.

1.3 When you reach for this API directly

Most of the time you don't — Sling Models and HTL hide the ResourceResolver behind @ValueMapValue and data-sly-resource. You reach for it directly when:

  • You are writing a servlet and need to read a resource other than the one the request resolved to.
  • You need a service user resolver to read content the current visitor cannot see (file 13, and again in section 2.4 here).
  • You are generating a link in Java and need the short, public-facing form of an internal path — map().
  • You are debugging why a path didn't resolve the way you expected.

1.4 A real project example to adapt

"On our energy site, most of our resource resolution is invisible — Sling Models and HTL do it for us. Where we touch the ResourceResolver directly is in three places: our Load More servlet, which reads sibling product pages under the current listing; a scheduled job that reads pricing data through a service user because the page tree it walks is partially access-controlled; and a link-building utility that calls map() so PDFs generated for download always contain public, shortened URLs rather than internal /content paths. Getting the third one wrong — forgetting the outbound half of URL mapping — is exactly the incident I'd bring up if asked for a project story here."

That paragraph does three things: shows you understand where the API is actually used, name-drops a service-user story (file 13's territory), and pre-loads the URL-shortening story from section 7.


2. Core Concepts

2.1 The ResourceResolver API — the methods you're actually asked about

Let's go through the API surface method by method, because interviewers frequently hand you two of these side by side and ask you to explain the difference.

getResource(String path) — the plain, honest lookup. Give it a path, it gives you back a Resource if one exists there, or null if it doesn't. No mapping is applied. No 404 page is generated. It is the JCR equivalent of asking "is anyone home at this exact address?"

There is an overload, getResource(Resource base, String relativePath), which resolves relative to a resource you already have — useful when walking a tree without rebuilding absolute paths by hand.

resolve(...) — covered in full in 2.2, because this is the one that trips people up.

map(...) — the outbound counterpart. Give it an internal repository path (and optionally the current request), and it gives you back the externally usable URL, applying whatever /etc/map rules or vanity paths apply. Section 2.10 onward covers this in depth.

adaptTo(Class<T> type) — not specific to ResourceResolver, but used constantly through it. Most commonly you'll see resolver.adaptTo(Session.class) to drop down to the raw JCR API when you need something the Sling Resource API doesn't expose, like a Session -level move or a workspace-level operation.

listChildren(Resource resource) — returns an Iterator<Resource> of the resource's direct children. This is what a Sling Model uses under the hood when you inject List<Resource> children — but knowing you can call it directly matters when you're walking a tree in a servlet or a service.

findResources(String query, String language) — runs a query (JCR-SQL2 or XPath) through the resolver and returns matching resources as an iterator. This is the lower-level cousin of QueryBuilder from file 01 — QueryBuilder is friendlier for building dynamic predicates, findResources is what you reach for when you already have a query string and don't want QueryBuilder's ceremony. The same indexing rules from file 01 apply: an unindexed query traverses and can hang the instance.

getSearchPath() — returns the configured search path as a String[], by default {"/apps/", "/libs/"}. This is the array Sling walks, in order, whenever it needs to turn a relative resource type into an absolute script path. Knowing this method exists is what lets you answer "how would you check the search path on this instance without reading OSGi config by hand?" — you'd call this, or check it from a script console.

getUserID() — returns the ID of the user this resolver is authenticated as. The fastest way to prove, in a debugging session, that you are looking at the resolver you think you are.

close() — releases the underlying session. Section 2.4 covers the rule that decides everything about this method: close what you opened, never close what you were handed.

2.2 resolve() versus getResource() — the distinction almost everyone gets wrong

This is the single most commonly mis-answered question in this whole file, so slow down here.

Both methods take a path and give you back a Resource. That surface similarity is exactly why people assume they're interchangeable. They are not, and the difference matters in production.

getResource(path) does a direct, literal lookup. No URL mapping. No selector or extension stripping. No search-path fallback for a relative resource type on the node itself. If nothing exists at that exact path, you get null.

resolve(request, path) — usually called as request.getResourceResolver().resolve(request) inside Sling's own request-handling code, or as resolver.resolve(path) in your own code — is the full pipeline. It:

  1. Applies /etc/map inbound rewriting, so a public-facing path gets translated to its internal repository path first.
  2. Strips selectors, extension and suffix from the incoming path before attempting to find the underlying resource.
  3. If, after all of that, nothing exists — it does not return null. It returns a special object called a NonExistingResource.

Say that last part out loud, because it's the part that gets tested:

getResource() on a missing path gives you null. resolve() on a missing path never gives you null — it gives you a NonExistingResource, which is a real, non-null Resource object whose resource type is set to a fixed marker value.

Why does Sling bother with this instead of just returning null in both cases?

Because resolve() is what runs at the very top of the request-processing pipeline — before Sling even knows whether this is going to be a 404. If resolve() returned null, every single piece of downstream code that expects a Resource object would need a null check just to handle "the page doesn't exist," and Sling's own script-resolution machinery (URL → Resource → Resource Type → Script from file 01) would break, because it needs a resource type to look up a script — even the 404 page is rendered through the same resourceType-to-script pipeline as everything else.

By giving you a NonExistingResource with resource type sling:nonexisting, Sling lets the same machinery that finds page.html for a real page also find a custom 404 script — placed at /apps/sling/nonexisting — for a resource that doesn't exist. One pipeline handles both cases.

The practical consequence for your own code:

// getResource -- direct, literal, can be null
Resource r1 = resolver.getResource("/content/energy/us/en/products/does-not-exist");
if (r1 == null) {
    // this branch is reachable
}

// resolve -- full pipeline, NEVER null, but can be a NonExistingResource
Resource r2 = resolver.resolve(request, "/content/energy/us/en/products/does-not-exist");
if (r2 == null) {
    // THIS BRANCH IS DEAD CODE. resolve() never returns null.
}
if (ResourceUtil.isNonExistingResource(r2)) {
    // this is the check you actually need
}

The interview answer:

" getResource() is a direct lookup — you give it a path, and you get either the resource or null. It doesn't apply any URL mapping. resolve() is the full pipeline Sling uses to turn an incoming request path into a resource: it applies /etc/map inbound rewriting first, strips selectors and extension, and — this is the part people miss — it never returns null. If nothing matches, it gives you back a NonExistingResource, which is a real object with a fixed resource type, sling:nonexisting. Sling does that so the same URL-to-script pipeline that renders every other page can also render a custom 404 page, just by placing a script at that resource type. So if I write if (resolver.resolve(path) == null), that branch is dead code — I need to check ResourceUtil.isNonExistingResource() instead."

2.3 NonExistingResource and SyntheticResource

Two "fake" resource types come up in interviews together, and it's worth being precise about what each is for.

NonExistingResource — already covered above. It exists specifically to represent "nothing is here" as a real Resource object, with resource type sling:nonexisting, so that 404 handling can go through the normal script-resolution pipeline instead of needing a special case.

/apps/sling/nonexisting/sling.html    ← your custom 404 page, matched by resource type

SyntheticResource — a Resource object that is not backed by an actual repository node at all. You can construct one yourself with new SyntheticResource(resolver, path, resourceType) whenever you need to hand something through the Resource API that has no real content behind it — a computed row in an aggregated listing, a fixture in a unit test, or a placeholder Sling itself needs internally when a path exists conceptually (an intermediate segment on the way to a real resource) but has no corresponding node.

The one-line distinction: a NonExistingResource specifically means "resolution failed, here's a stand-in so the pipeline keeps working." A SyntheticResource more broadly means "here's a Resource with no repository node behind it," and you can create those yourself for your own purposes, whereas you would never construct a NonExistingResource by hand — Sling gives you one.

2.4 Closing rules — the one habit that prevents a production incident

This continues directly from files 07 and 13, and it is worth restating here because this file is where the rule actually lives.

The rule, in one sentence: close a resolver only if you opened it yourself.

request.getResourceResolver() in a servlet, or the resolver backing a Sling Model's injected fields, belongs to Sling. Sling opened it when the request came in, and Sling will close it when the request finishes. If you call .close() on it yourself, you don't get an error immediately — you get everything after that call in the same request failing, because the JCR session backing it is now gone.

A resolver you obtain from resourceResolverFactory.getServiceResourceResolver(...) (file 13's service-user pattern) is different: you opened it, so you must close it — always in try-with-resources, never in a finally block you might forget to write correctly.

// You opened this one. You close it. Always try-with-resources.
try (ResourceResolver serviceResolver =
        resolverFactory.getServiceResourceResolver(authInfo)) {

    Resource priced = serviceResolver.getResource("/content/energy/pricing/2026");
    // ... use it while the resolver is open ...

}   // closed automatically, even if an exception was thrown above

2.5 What a leaked resolver actually looks like in production

This is worth walking through slowly, because "session leak" is an abstract phrase until you've seen what it does.

Every ResourceResolver you open from a factory holds open a real JCR Session underneath it. A Session is not free — it holds memory, and in a clustered Oak setup it can hold onto resources that the garbage collector cannot reclaim, because something still technically has a reference to it: your unclosed resolver.

If a piece of code — say, a scheduled job or a servlet that runs on every request — opens a service-user resolver and forgets to close it, here is the timeline you actually see:

  1. Day one: nothing looks wrong. The instance has plenty of headroom.
  2. Over days or weeks: heap usage on that instance creeps upward in a way that doesn't correlate with traffic. It goes up and never comes back down after a GC cycle, because live references are pinning those sessions.
  3. Eventually: the instance starts throwing OutOfMemoryError, or session-related warnings appear in the logs about too many open sessions, and a restart "fixes" it — until the leak accumulates again.

Why this is hard to diagnose the first time you see it: the symptom (an instance slowly dying) looks nothing like the cause (one missing .close() in one servlet). Nobody's first instinct is "check for unclosed resolvers" when heap usage is climbing — they look at traffic, at cache sizes, at the DAM. The diagnostic order that actually gets you there fast is in section 11.

2.6 The search path and how a resource type becomes a script — recap and one layer deeper

File 01 gave you the eight-step script resolution order ending in " /apps before /libs." Here is the one thing worth adding on top of that, because it's what interviewers probe for when they think you've only memorised the list.

The search path is configuration, not a hardcoded constant. It comes from the Sling Resource Resolver Factory's OSGi configuration — the same place service-user mappings live (file 13) — and while /apps then /libs is the standard order almost every project keeps, it is technically a list you could reorder or extend. You will essentially never see a project that changes it, but knowing it's configuration rather than a law of physics is the difference between reciting a fact and understanding a mechanism.

sling:resourceType versus sling:resourceSuperType — the one-sentence distinction that matters here:

  • sling:resourceType says what this resource is. It's the starting point of script resolution.
  • sling:resourceSuperType says what to fall back to if a script isn't found at the resource type's own path. It's how one component can inherit another's rendering and dialog without being a copy of it.

This second one is the mechanism behind proxy components (file 02) and it is also, as you'll see in section 2.9, one of the two ways the Resource Merger stitches content together — not just the /apps -over- /libs search path, but also the resourceSuperType chain.

2.7 Overlay, Inheritance, and Override — the comparison interviewers actually ask for

These three words get used loosely in everyday project conversation, and that looseness is exactly what an interviewer is testing when they ask you to tell them apart. Treat this as three genuinely different mechanisms that happen to solve related problems.

Overlay. You place a resource at the same relative path under /apps as one that exists under /libs. Because the search path checks /apps first, your version is found first and effectively hides Adobe's. Nothing about the original at /libs changes — it's still there, just never reached, because /apps won the race.

This is a path-based, global mechanism. It has no concept of "just this one component" — if two completely unrelated things happen to resolve through the same relative path, overlaying it affects both. That's what makes it powerful and also the riskiest of the three.

Inheritance. A resource declares sling:resourceSuperType, pointing at a different resource type entirely — usually a Core Component, or a shared base component within your own project. If a script or dialog isn't found at your resource's own path, resolution falls through to the resource type named in resourceSuperType, and keeps falling through the chain until something answers or the chain ends.

This is a scoped, deliberate relationship — you are explicitly opting one component into another's behaviour. It has nothing to do with /apps versus /libs; you can inherit from a component that lives entirely under /apps, as proxy components do (file 02).

Override. Complete replacement, with no fallback and nothing inherited. You aren't extending or shadowing something — you're saying "ignore whatever else exists at any lower-priority location; use exactly and only what I've provided here." Section 2.9 covers the concrete mechanism the Resource Merger gives you for this: the /mnt/override mount, as distinct from the merging behaviour of /mnt/overlay.

The comparison, side by side:

OverlayInheritanceOverride
MechanismSame relative path, /apps before /libssling:resourceSuperType chainComplete replacement, no merge
ScopeGlobal — anything resolving to that pathScoped — only components that opt inScoped to the resource declaring it
What survives from the originalNothing reachable, but the original still exists at /libsWhatever you don't explicitly redefineNothing — that's the point
Risk profileHigher — you can't tell everything that resolves through that pathLower — you know exactly what opted inDepends entirely on what you left out
Typical useCustomising the AEM authoring UI itself (a console, a classic dialog)Extending a Core Component or a proxyReplacing an entire dialog or script wholesale, deliberately
Upgrade safetyYou silently stop receiving Adobe's fixes to that exact pathYou keep receiving fixes to everything you didn't overrideYou keep nothing from upstream at all

Why overlay is riskier, stated plainly: when you overlay /libs/foo/bar with /apps/foo/bar, you have frozen your copy in time. The next time Adobe patches or improves /libs/foo/bar, you don't get it — your /apps copy still wins the search path, and it's now stale relative to the product. This is exactly the same risk file 01 warned about for editing /libs directly, except overlay doesn't corrupt the original — it just silently shadows improvements to it. Nobody notices until a security patch to that exact path ships and doesn't take effect on your instance.

Why inheritance is safer, stated plainly: because you only redefine the specific pieces you touch — one new dialog field, one changed HTL block — everything else keeps flowing through to the real component. When Adobe ships a Core Component update, your proxy still inherits all of it automatically; you only "own" the small delta you actually wrote.

The interview answer:

"They solve related problems but they're mechanically different. Overlay is path-based — same relative location under /apps as under /libs, and the search path means yours wins. It's global, which is also its risk: you can't always tell everything that resolves through that path, and you silently stop receiving Adobe's improvements to it. Inheritance is sling:resourceSuperType — a deliberate, scoped relationship where a component opts into extending another one, and it's how proxy components and Core Component customisation work; you keep receiving upstream improvements to everything you didn't specifically redefine. Override is the least common of the three — a complete replacement with no fallback at all, which the Resource Merger gives you explicitly through /mnt/override as distinct from the merging behaviour of /mnt/overlay. I default to inheritance, reach for overlay only to customise the authoring UI itself, and use override rarely and deliberately."

2.8 The Sling Resource Merger — the problem it actually solves

Here is the scenario that makes the Resource Merger necessary, told the way it actually happens on a project.

You are three weeks into the energy site build. A stakeholder asks for one extra field on the page properties dialog — a "Regulatory Region" dropdown, because pages need to be tagged for compliance reasons that have nothing to do with anything Adobe anticipated. The out-of-the-box page properties dialog has a dozen tabs and does dozens of things correctly already — SEO metadata, social sharing, page thumbnails, cloud configuration references. You need exactly one more field.

The wrong way to do this — and the way almost every AEM developer tries first — is to copy the entire OOTB dialog node from /libs down to /apps, then add your one field to the copy. It works. It also means:

  • You now own a full duplicate of Adobe's dialog structure, frozen at whatever version you copied it from.
  • Every future Adobe fix, every new field Adobe adds to page properties in a later service pack or Cloud Service release, never reaches you — your copy doesn't know it exists.
  • Six months later, nobody remembers this copy was made for one field, and a code reviewer has no way to tell "this diverged from Adobe's version deliberately" from "this diverged from Adobe's version because someone forgot to update it."

The Resource Merger exists specifically to avoid this. Instead of copying the whole dialog, you create a resource under /apps at the relevant path that contains only the new field, and the Resource Merger combines — merges — your small addition with Adobe's full dialog at read time, every time it's rendered. You never own more than the delta you actually wanted.

Why this is worth stating as the "why" before the "how": an interviewer who asks about the Resource Merger is really asking "do you understand the cost of copying product code, and do you know the alternative?" Leading with the cost of copying, the way this section just did, answers the reason the mechanism exists before you even describe the mechanism itself.

2.9 The two mount points — /mnt/overlay and /mnt/override

This is the part of the Resource Merger that most candidates who "know the name" cannot actually explain, and it's where you can visibly separate yourself from someone who has only heard the term.

The Resource Merger produces its combined view of the world at two special, virtual mount points. Neither of these is something you write content into directly — you keep writing your actual content under /apps, at the same relative path as the thing you're extending, and the Resource Merger computes the merged (or overridden) view on the fly whenever something reads through one of these paths.

/mnt/overlay/<path> — the merging view. For every resource in the search path (by default /apps and /libs, in priority order), the Resource Merger combines them into one logical resource:

  • Properties present in the higher-priority location (/apps) win over the same property name from the lower-priority one (/libs).
  • Properties that exist only at the lower-priority location are still present in the merged result — nothing is lost by default.
  • Child resources are combined the same way, recursively — your one new child node under /apps appears alongside all of Adobe's existing children from /libs.

This is the mount point behind almost everything people mean when they casually say "the Resource Merger" — it's how AEM computes the effective page properties dialog, and how it computes the effective dialog for a component when a proxy adds a field on top of a Core Component it inherits from.

/mnt/override/<path> — the overriding view. Instead of combining everything found in the search path, this mount point picks up only the single highest-priority resource that exists, and uses it exactly as-is — no merging with anything lower in the search path at all. If /apps/foo/bar exists, that is the entire answer; /libs/foo/bar is never consulted, not even for properties /apps/foo/bar doesn't define.

The plain-English distinction, worth having ready:

" /mnt/overlay combines what it finds across the search path — your addition plus everything from /libs you didn't touch. /mnt/override picks only the highest-priority one it finds and stops there, with nothing merged in from underneath. Overlay is for extending; override is for replacing outright."

Why you rarely reach for /mnt/override deliberately: almost every real customisation is additive — one more field, one more tab, one hidden property. Genuine full replacement is rare enough that most projects go their entire lifetime without a developer needing to invoke override semantics on purpose. It exists for the cases where merging genuinely isn't what you want — for example, a dialog so different from the original that trying to "hide" your way to the result would be more code than just replacing it.

graph TD subgraph SP["Search path resources"] LIBS["/libs/foo/bar<br/>jcr:title = 'Original'<br/>field-a, field-b"] APPS["/apps/foo/bar<br/>jcr:title = 'Energy Override'<br/>field-c (new)"] end LIBS --> OVERLAY APPS --> OVERLAY OVERLAY["/mnt/overlay/foo/bar<br/><b>MERGED:</b><br/>jcr:title = 'Energy Override' (apps wins)<br/>field-a, field-b (kept from libs)<br/>field-c (added from apps)"] LIBS -.->|"never consulted"| OVERRIDE APPS --> OVERRIDE OVERRIDE["/mnt/override/foo/bar<br/><b>OVERRIDE:</b><br/>jcr:title = 'Energy Override'<br/>field-c only<br/>(field-a, field-b are GONE)"]

2.10 The properties that control the merge

Four properties, placed on the resource you create under /apps, give you fine control over the merged result. All four live in the sling: namespace, and all four only make sense in the context of the /mnt/overlay merging behaviour — they're how you selectively remove or reposition pieces of the lower-priority resource rather than accepting everything from it by default.

sling:hideResource (Boolean) — placed on a node to say "this entire resource, at this path, should not appear in the merged result, even though something exists here at a lower priority." This is a full hide — the merged view behaves as if nothing exists at this path at all.

sling:hideProperties — a String or String array naming specific property names to remove from the merged result, even though they exist on the lower-priority resource. Use "*" to hide every inherited property at that node, keeping only what you define yourself.

sling:hideChildren — the same idea, but for child resources rather than properties. Name specific child node names to exclude, or "*" to exclude everything inherited and keep only children you've explicitly added.

sling:orderBefore — a String naming a sibling node. Positions your added or merged child before that named sibling in the merged child ordering. Without this, a newly added field or tab tends to land at the end, which is rarely where you want it — a "Regulatory Region" field added to page properties should sit near the other compliance fields, not trail after everything else.

A concrete example, put together:

Say Adobe's dialog under /libs has a "SEO" tab containing fields metaTitle, metaDescription, and robotsTag. You want to: keep metaTitle and metaDescription, remove robotsTag because your project handles that a different way, and add a new field regulatoryRegion, positioned right after metaDescription.

<!-- /apps/energy/.../seo (nt:unstructured) -->
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0"
    jcr:primaryType="nt:unstructured"
    sling:hideChildren="robotsTag">
    <!--
      This node itself is NOT hidden -- it merges with the /libs "seo"
      tab. hideChildren removes just robotsTag from the merged children.
    -->
    <regulatoryRegion
        jcr:primaryType="nt:unstructured"
        sling:orderBefore="robotsTag"
        sling:resourceType="granite/ui/components/coral/foundation/form/select"
        fieldLabel="Regulatory Region"
        name="./regulatoryRegion">
        <items jcr:primaryType="nt:unstructured">
            <eu jcr:primaryType="nt:unstructured" value="eu" text="European Union"/>
            <na jcr:primaryType="nt:unstructured" value="na" text="North America"/>
            <apac jcr:primaryType="nt:unstructured" value="apac" text="Asia-Pacific"/>
        </items>
    </regulatoryRegion>
</jcr:root>

Notice sling:orderBefore="robotsTag" even though robotsTag is about to be hidden. That's deliberate and worth explaining if asked: ordering is resolved against the full merged child list before hiding is applied, so naming the sibling you're about to hide is still a valid, stable way to say "put me exactly where that field used to be." (If that makes you uneasy about ordering drifting when the hidden sibling itself moves in a future Adobe update, that instinct is correct — it's one of the few genuine downsides of this approach, and it's why some teams anchor orderBefore against a field they know Adobe treats as stable, like the tab's first item, rather than one that might be reordered upstream.)

2.11 The classic use cases, tied to files 02 and 22

Extending page properties. Exactly the scenario in 2.8 — add a field or a whole new tab to Adobe's out-of-the-box page properties dialog. The dialog itself lives under /libs (the exact path has shifted slightly across AEM versions — historically the classic Foundation page dialog sat at /libs/foundation/components/page/dialog; the mechanism is identical regardless of the exact path in your version). You place a resource at the same relative path under /apps, containing only your addition, and the /mnt/overlay merge does the rest.

One nuance worth stating precisely, because it's a genuine gotcha: on a project built with editable templates — which is the standard on AEM as a Cloud Service and almost every modern project, including our energy site — each template defines its own page properties structure under /conf/<project>/settings/wcm/templates/<template>/, which is content you already own outright. In that world you often don't need the Resource Merger for page properties at all; you edit your own /conf structure directly. The Resource Merger's page-properties use case is most relevant for static/classic templates, or for extending shared authoring dialogs and consoles that genuinely still live in /libs. Knowing this distinction — and saying so unprompted — is a strong signal that you've actually built on editable templates rather than only read about the classic pattern.

Extending a Core Component's dialog (file 02's territory). This is the more common real case on a modern project, and it works through inheritance, not the /apps -over- /libs overlay path. Recall from file 02: your project creates a proxy component — /apps/energy/components/teaser with sling:resourceSuperType pointing at the Core Component, e.g. core/wcm/components/teaser/v2/teaser. Core Components install under /apps themselves (they ship as a regular content package your project depends on, not as true product code under /libs), so there's no same-path overlay opportunity here at all — your proxy is at a completely different absolute path.

What makes the field addition work is that AEM's dialog-computation logic doesn't just merge same-path /apps -versus- /libs resources — it also walks the sling:resourceSuperType chain when computing the effective dialog for a resource type, merging your proxy's own (partial) _cq_dialog with whatever it inherits from the Core Component it points at, using the exact same sling:hideResource / sling:hideProperties / sling:orderBefore vocabulary. This is why file 02 could already tell you "create a dialog containing only the new field, plus sling:orderBefore " without needing to talk about mount points at all — the mount points are the deeper mechanism underneath that same-looking recipe.

Why this beats copying, restated once more because it's the whole point: in both cases, you keep receiving Adobe's improvements to the thing you extended. A Core Component security fix, a new field Adobe adds in a later version, a page-properties enhancement in the next AEMaaCS release — all of it reaches you automatically, because you never stopped consuming the original. You only own the small, explainable delta.

2.12 The problem /etc/map solves

Back in file 01, section 3.3 gave you the short version: Sling Mapping under /etc/map gets rid of /content/energy/us/en in your public URLs. Here is the full mechanism, because "how do we get clean URLs" turns into a much longer conversation the moment someone asks both directions.

The setup. Your content genuinely lives at /content/energy/us/en/home. Nobody wants that in a browser's address bar, in a marketing email, or in a partner's inbound link. You want https://www.energy-corp.com/us/en/home instead.

Inbound. A visitor requests /us/en/home.html. Before Sling can resolve that to a resource, something has to translate it to the real repository path, /content/energy/us/en/home. That translation is a sling:Mapping node under /etc/map, using two properties:

  • sling:match — describes the incoming request to match: typically the scheme, host, port, and path prefix, roughly in the shape host[.port][/path-prefix].
  • sling:internalRedirect — the real repository path to resolve to instead, once matched. This is a transparent, internal rewrite — the visitor's browser never sees it and the address bar doesn't change.
/etc/map
  /http
    map-us-en (sling:Mapping)
      sling:match             = "www.energy-corp.com.80/us/en"
      sling:internalRedirect  = "/content/energy/us/en"

Outbound. This is the half people forget, and it's the whole subject of section 7's first story. When your own code or your HTL templates generate a link — a "related product" link, a breadcrumb, a canonical URL tag — the value you have in hand is the internal path, /content/energy/us/en/products/hvdc-converter. If you write that straight into an <a href>, visitors get a working link that exposes the internal repository structure, defeating the entire point of setting up mapping in the first place.

resourceResolver.map(request, path) is the method that does the reverse translation — it looks at the same set of mapping rules and, for a given internal path, produces the shortened, public form. Call it (or let AEM's built-in link rewriter transformer, which post-processes rendered HTML and rewrites href / src attributes automatically, do it for you) whenever you write a path-derived link.

There's also a distinct property, sling:redirect, which behaves differently from sling:internalRedirect. Where internalRedirect is a silent, internal rewrite, sling:redirect (Boolean, or sometimes paired with a target) causes Sling to send back an actual HTTP redirect response — the browser's address bar changes and the client makes a second request. You'd reach for this for something like retiring an old domain and sending every request on it to the new one, not for everyday clean-URL rewriting.

The complete answer, worth having word-for-word ready — this expands directly on file 01's version:

"Sling Mapping under /etc/map handles it, and the mistake most people make is only implementing one direction. Inbound, a sling:Mapping node's sling:match and sling:internalRedirect properties silently rewrite the short public URL to the real content path before Sling resolves it — the browser never sees this happen. Outbound, when my own code or HTL generates a link from an internal path, I have to call resourceResolver.map(), or rely on AEM's link rewriter transformer to do it across the rendered HTML — otherwise every generated link still points at /content/energy/us/en/... even though the page you're standing on has a clean URL. Get only the inbound half right and you get exactly the bug you'd expect: a beautiful short URL in the address bar, and every single link on that page pointing at an internal repository path. We also layer Apache rewrite rules in front of the dispatcher for some of this, and on Cloud Service, Adobe gives you an equivalent CDN-level rewrite mechanism through the config pipeline instead of hand-editing Apache config."

resourceResolver.map() has two common overloads:

  • map(String resourcePath) — maps using no request context, useful outside a request (a scheduled job building an email, for instance).
  • map(HttpServletRequest request, String resourcePath) — maps with the current request's scheme and host taken into account, which matters when the same content is reachable through more than one public hostname (a country-specific domain versus a shared global one, for example).

Both consult the same /etc/map configuration, and both will also consider a vanity path (2.15) on the target resource if one is set — map() doesn't only know about /etc/map rules, it's the single entry point for "give me the best public URL for this internal path," and it picks whichever mechanism produces a usable result.

The link rewriter transformer is the alternative to calling map() by hand everywhere. It's a Sling rewriter pipeline component that post-processes the entire rendered HTML output of a page, finding href and src attributes that look like internal repository paths and substituting the mapped, external form — so you get outbound mapping applied consistently across a page even where a developer forgot to call map() explicitly in one component's HTL. Most projects rely on this as the safety net, and call map() directly only in code paths that don't produce ordinary rendered HTML — an email template, a PDF export, a JSON API response.

2.14 Interaction with the dispatcher and Apache rewrite rules

URL mapping inside AEM and rewrite rules inside your dispatcher's Apache configuration are two separate layers that have to agree with each other, and this is a genuinely common source of "it works in the CMS but not on the live site" bugs.

Sling's mapping under /etc/map decides how AEM itself resolves and generates URLs. Apache's mod_rewrite rules, sitting in front of the dispatcher, decide what the browser is allowed to type or receive before the request even reaches AEM. If a marketing team wants https://www.energy-corp.com/products/ (no country code at all) to transparently serve /content/energy/us/en/products, you typically need both: an Apache rewrite rule that accepts the friendly incoming path and forwards it appropriately, and a Sling mapping so that AEM's own internal map() / resolve() calls understand the same relationship for any content generated dynamically. Set up only the Apache side and internal links generated by AEM (breadcrumbs, canonical tags, sitemap entries) will still show the old form; set up only the Sling side and a request from a browser that never goes through the expected Apache rule won't be recognised at all.

2.15 On AEM as a Cloud Service — CDN-level rewrite rules

On AEMaaCS, you don't own an Apache VM in front of your publish tier the way you did on 6.5 (file 01, section 3.5) — the dispatcher runs as a container alongside each publish pod, and Adobe manages the CDN in front of everything. For the class of rewrite that used to live in a hand-edited Apache config, Adobe instead gives you a declarative rewrite configuration deployed through the Cloud Manager pipeline, applied at the CDN edge before a request ever reaches your dispatcher or publish tier. The underlying reason you'd want this hasn't changed — friendly incoming paths, redirecting an old domain, stripping or adding path segments — only where the rule lives and how it's deployed has moved, consistent with the general "everything through the pipeline, nothing hand-edited on a box" shift file 01 covers in the 6.5-versus-Cloud-Service comparison.

2.16 Vanity URLs — a different mechanism entirely, and how it differs from mapping

It's tempting to lump vanity URLs in with /etc/map, because both produce short, friendly public URLs. They are genuinely different mechanisms, solving a different shaped problem.

sling:vanityPath is a property (String, or a String array for more than one) placed directly on a page's jcr:content node. Whatever value you put there becomes an additional, alternate path that resolves straight to that page — set by a content author, per page, without touching /etc/map, without a developer, and without a deployment.

/content/energy/us/en/campaigns/summer-grid-modernization/jcr:content
    sling:vanityPath = "/grid2026"

Now https://www.energy-corp.com/grid2026 resolves directly to that campaign page.

How it differs from /etc/map mapping, in the ways that matter:

/etc/map mappingsling:vanityPath
Who configures itA developer, in /etc/map configAn author, as a page property
ScopeA prefix pattern applying to a whole subtreeOne specific page
Deployment neededYes — content package or config changeNo — saved like any content edit
Typical useStripping a whole locale/site prefix, globallyA short campaign or landing-page URL
How it's discovered at startupStatic config, read onceBuilt by scanning the repository for the property, requiring an index

A property that pairs with it: sling:redirect (Boolean) on the same node. Left unset, the vanity path resolves internally — the visitor keeps seeing the short URL in the address bar, with no visible redirect. Set to true, requesting the vanity path instead causes an actual HTTP redirect to the page's real, canonical URL — useful when you want the vanity path to work as a shareable shortcut, but you want analytics and bookmarks to settle on the canonical URL rather than the shortcut.

A production detail worth knowing: because vanity paths are discovered by scanning the whole repository for the sling:vanityPath property, this needs a proper Oak index behind it (the same "every query needs an index" rule from file 01) — on a repository with a very large number of pages and no dedicated index for it, vanity path resolution can become measurably slow, and a missing or broken index here is a genuine, if less obvious, production scenario (see section 13).


3. Internal Working

3.1 The full resolution sequence, both directions

This extends file 01's request-flow diagram by zooming into exactly the step where mapping and resolution happen, and by showing the outbound side that the file 01 diagram didn't cover at all.

sequenceDiagram participant B as Browser participant D as Dispatcher participant M as Sling Mapping (/etc/map) participant R as ResourceResolver.resolve() participant V as Vanity Path lookup participant J as JCR / Oak participant S as Script resolution B->>D: GET /us/en/home.html D->>R: forward (cache miss) R->>M: check /etc/map inbound rules M-->>R: sling:internalRedirect -> /content/energy/us/en/home R->>V: also check for a matching vanity path V-->>R: none for this path -- continue R->>J: does a node exist there, and can THIS USER read it? alt resource exists and readable J-->>R: Resource R->>S: read sling:resourceType, find script S-->>B: rendered response else missing, or ACL denies J-->>R: nothing R-->>B: NonExistingResource -> 404 page via sling:nonexisting end Note over B,S: --- OUTBOUND, generating a link on that page --- S->>R: resolver.map(request, "/content/energy/us/en/products/x") R->>M: any matching sling:internalRedirect target? reverse it R->>V: any sling:vanityPath set on that resource? prefer it R-->>S: "/us/en/products/x" (short, public form) S-->>B: <a href="/us/en/products/x">

The two things worth narrating out loud from this diagram, because they're the parts a memorised list skips:

One — the permission check happens at the same step as the mapping and the existence check. resolve() isn't "find the node, then separately check if the user can see it" — the resource genuinely does not resolve at all for a user without read access. That's why an anonymous visitor and a logged-in author can hit the identical URL and get a 404 versus a real page: same resolution logic, different resolver, different outcome.

Two — outbound mapping is not automatic just because inbound mapping exists. It is a distinct call (map()), consulted separately, every time a link is generated. Nothing forces a developer to make that call; forgetting it is section 7's first story.

3.2 The Resource Merger's actual merge algorithm

graph TD A["Something asks for /mnt/overlay/apps/energy/.../cq:dialog"] --> B["Resource Merger walks the search path,<br/>highest priority first: /apps, then /libs"] B --> C{"Does a resource exist<br/>at this relative path in /apps?"} C -->|Yes| D["Take its properties as the base.<br/>Apply sling:hideProperties/hideChildren<br/>if present."] C -->|No| E["Nothing to contribute at this level --<br/>fall through to the next search path entry"] D --> F["Merge in properties/children from /libs<br/>that were NOT hidden and NOT already<br/>overridden by /apps"] E --> F F --> G{"Does the RESOURCE also declare<br/>sling:resourceSuperType?"} G -->|Yes| H["Repeat the same merge against<br/>the super type's resource tree<br/>(this is how a proxy extends<br/>a Core Component's dialog)"] G -->|No| I["Merged result returned"] H --> I I --> J["Apply sling:orderBefore to<br/>position merged children"]

The detail worth calling out unprompted: the merge doesn't stop after combining /apps and /libs at the same path — for a resource that also declares sling:resourceSuperType, the merger keeps going, walking that inheritance chain too. This is exactly why a proxy component's tiny _cq_dialog, containing only one new field, ends up producing a complete, working dialog: two separate merge mechanisms — same-path search order, and resourceSuperType inheritance — are both feeding into the one result AEM actually renders.

3.3 Comparing getResource(), resolve(), and map() as one flow

graph LR subgraph Inbound["Turning a URL into content"] U["/us/en/home.html"] -->|"resolve()"| RM["/etc/map<br/>inbound rewrite"] RM --> RES["/content/.../home"] RES -->|"ACL check happens here"| RESULT1["Resource, or<br/>NonExistingResource"] end subgraph Direct["A literal path lookup"] P["/content/.../home"] -->|"getResource()"| RESULT2["Resource, or null<br/>-- no mapping applied"] end subgraph Outbound["Turning content into a link"] C["/content/.../products/x"] -->|"map()"| MM["/etc/map reverse +<br/>vanity path check"] MM --> RESULT3["/us/en/products/x<br/>-- short, public form"] end


8. Coding/Configuration Examples

8.1 resolve() vs getResource() side by side, with the null-safety consequence

package com.energy.core.servlets;

import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceUtil;

public class ResolutionExamples {

    /**
     * getResource(): a literal, direct lookup.
     * NO url mapping is applied. Returns null if nothing exists here --
     * this null check is real and reachable.
     */
    public boolean pageExistsAt(ResourceResolver resolver, String path) {
        Resource resource = resolver.getResource(path);
        return resource != null;
    }

    /**
     * resolve(): the FULL pipeline -- applies /etc/map inbound rewriting,
     * strips selectors/extension, and NEVER returns null.
     *
     * A missing resource comes back as a NonExistingResource: a real,
     * non-null object with resource type "sling:nonexisting".
     */
    public boolean pageActuallyResolves(SlingHttpServletRequest request, String path) {
        Resource resource = request.getResourceResolver().resolve(request, path);

        // WRONG -- this branch is dead code, resolve() never returns null:
        // if (resource == null) { return false; }

        // RIGHT -- this is the actual "does it exist" check for resolve():
        return !ResourceUtil.isNonExistingResource(resource);
    }
}

8.2 A service-user resolver, opened and closed correctly

package com.energy.core.services.impl;

import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collections;
import java.util.Map;

@Component(service = PricingReaderService.class)
public class PricingReaderServiceImpl implements PricingReaderService {

    private static final Logger LOG = LoggerFactory.getLogger(PricingReaderServiceImpl.class);
    private static final String SUBSERVICE = "pricing-reader";

    @Reference
    private ResourceResolverFactory resolverFactory;

    @Override
    public String readCurrentRegionPricing(String region) {

        Map<String, Object> params =
                Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SUBSERVICE);

        // I opened this resolver, so I close it. Always try-with-resources --
        // an early return or an exception below still triggers close().
        try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(params)) {

            Resource pricing = resolver.getResource("/content/energy/pricing/" + region);
            if (pricing == null) {
                LOG.warn("No pricing data found for region {}", region);
                return null;
            }
            return pricing.getValueMap().get("currentRate", String.class);

        } catch (LoginException e) {
            // The service user mapping itself failed -- almost always a
            // Repoinit / ServiceUserMapper configuration problem (file 13).
            LOG.error("Could not obtain the pricing-reader service resolver", e);
            return null;
        }
    }
}

8.3 A link-building utility that always calls map() — the fix from Story 1

package com.energy.core.utils;

import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.ResourceResolver;

/**
 * Every path-derived link in the project should go through here, so that
 * outbound URL mapping is never something an individual component has to
 * remember to do. This is the fix for a rollout where inbound mapping
 * was set up correctly but outbound mapping was simply forgotten --
 * clean URL in the address bar, /content paths in every link.
 */
public final class LinkBuilder {

    private LinkBuilder() { }

    public static String toPublicUrl(SlingHttpServletRequest request, String internalPath) {
        if (internalPath == null) {
            return null;
        }
        ResourceResolver resolver = request.getResourceResolver();

        // map() also checks for a vanity path on the target resource and
        // prefers it over a plain /etc/map rewrite when one is set.
        return resolver.map(request, internalPath);
    }
}
<!-- In HTL, always go through the model rather than printing the raw path -->
<a data-sly-test="${relatedProduct.publicUrl}" href="${relatedProduct.publicUrl}">
    ${relatedProduct.title}
</a>

8.4 /etc/map — the inbound mapping node

<!-- /etc/map/http/map-us-en/.content.xml -->
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
    jcr:primaryType="sling:Mapping"
    sling:match="www.energy-corp.com.80/us/en"
    sling:internalRedirect="/content/energy/us/en"/>
<!-- A domain-level redirect, using sling:redirect instead of internalRedirect --
     this one causes a REAL HTTP redirect, not a silent internal rewrite. -->
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
    jcr:primaryType="sling:Mapping"
    sling:match="www.old-energy-domain.com.80"
    sling:redirect="https://www.energy-corp.com"/>

8.5 A vanity URL on a campaign page

<!-- /content/energy/us/en/campaigns/summer-grid-modernization/jcr:content -->
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0"
    xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
    jcr:primaryType="cq:PageContent"
    jcr:title="Summer Grid Modernization Campaign"
    sling:vanityPath="/grid2026"
    sling:vanityOrder="{Long}100"/>
    <!--
      No /etc/map entry needed for this -- an author sets this property
      directly, and it takes effect without a deployment. Add sling:redirect
      = {Boolean}true here to make requesting the vanity path issue a real
      HTTP redirect to the canonical URL instead of resolving silently.
    -->

8.6 The Resource Merger — extending page properties (full worked example)

<!--
  /apps/energy/wcm-overlays/.../seo/.content.xml
  (placed at the SAME RELATIVE PATH under /apps as the "seo" tab node
  under /libs -- this is what makes /mnt/overlay merge the two)

  GOAL: keep metaTitle and metaDescription from Adobe's dialog, remove
  robotsTag, and add regulatoryRegion positioned where robotsTag used
  to sit.
-->
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
    jcr:primaryType="nt:unstructured"
    sling:hideChildren="robotsTag">

    <items jcr:primaryType="nt:unstructured">
        <regulatoryRegion
            jcr:primaryType="nt:unstructured"
            sling:orderBefore="robotsTag"
            sling:resourceType="granite/ui/components/coral/foundation/form/select"
            fieldLabel="Regulatory Region"
            fieldDescription="Used by compliance reporting to filter content by jurisdiction."
            name="./regulatoryRegion">
            <items jcr:primaryType="nt:unstructured">
                <eu jcr:primaryType="nt:unstructured" value="eu" text="European Union"/>
                <na jcr:primaryType="nt:unstructured" value="na" text="North America"/>
                <apac jcr:primaryType="nt:unstructured" value="apac" text="Asia-Pacific"/>
            </items>
        </regulatoryRegion>
    </items>
</jcr:root>

8.7 The Resource Merger — extending a Core Component's dialog via a proxy (ties to file 02)

<!--
  /apps/energy/components/teaser/.content.xml  (the PROXY component)

  This is a DIFFERENT mechanism from 8.6 -- there is no /libs copy of
  this exact path to merge with, because the proxy's path is entirely
  our own. The merge happens across sling:resourceSuperType instead.
-->
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
    jcr:primaryType="cq:Component"
    jcr:title="Energy Teaser"
    sling:resourceSuperType="core/wcm/components/teaser/v2/teaser"/>
<!--
  /apps/energy/components/teaser/_cq_dialog/.content.xml

  This dialog contains ONLY the new field. AEM's dialog computation
  walks the resourceSuperType chain and merges this with the FULL
  Core Component Teaser dialog -- same hide/order vocabulary as 8.6,
  different trigger (inheritance, not same-path overlay).
-->
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
    jcr:primaryType="nt:unstructured"
    jcr:title="Teaser">
    <content jcr:primaryType="nt:unstructured" sling:resourceType="granite/ui/components/coral/foundation/container">
        <items jcr:primaryType="nt:unstructured">
            <tabs jcr:primaryType="nt:unstructured" sling:resourceType="granite/ui/components/coral/foundation/tabs">
                <items jcr:primaryType="nt:unstructured">
                    <energySpecific jcr:primaryType="nt:unstructured" jcr:title="Energy Sector"
                        sling:resourceType="granite/ui/components/coral/foundation/container">
                        <items jcr:primaryType="nt:unstructured">
                            <productLine
                                jcr:primaryType="nt:unstructured"
                                sling:resourceType="granite/ui/components/coral/foundation/form/select"
                                fieldLabel="Product Line"
                                name="./productLine">
                                <items jcr:primaryType="nt:unstructured">
                                    <transformers jcr:primaryType="nt:unstructured" value="transformers" text="Transformers"/>
                                    <hvdc jcr:primaryType="nt:unstructured" value="hvdc" text="HVDC Systems"/>
                                    <grid jcr:primaryType="nt:unstructured" value="grid" text="Grid Automation"/>
                                </items>
                            </productLine>
                        </items>
                    </energySpecific>
                </items>
            </tabs>
        </items>
    </content>
</jcr:root>

Notice the shape difference between 8.6 and 8.7. In 8.6, the resource I wrote is a small delta node sitting inside Adobe's existing dialog structure — it merges at a granular level because the path lines up exactly. In 8.7, my dialog adds a whole new tab, because the proxy pattern typically presents new, project-specific fields as their own clearly separated section rather than trying to slot individual fields into Adobe's existing tabs — both are valid uses of the same underlying merge machinery, chosen based on how naturally the addition fits the original structure.

8.8 Reading the merged view directly, for debugging

// Handy in a script console or a debugging service: read the MERGED view
// directly, rather than /apps or /libs individually, to see exactly what
// AEM itself will actually use.
Resource merged = resolver.getResource(
        "/mnt/overlay/energy/wcm-overlays/apps/wcm/core/content/components/page/dialog/content/items/tabs/items/seo");

if (merged != null) {
    merged.getValueMap().forEach((key, value) ->
            System.out.println(key + " = " + value));
}

9. Common Mistakes

The mistakeWhat actually happensThe fix
Treating resolve() and getResource() as interchangeableNull checks written for resolve() are dead code; a NonExistingResource slips through as if it were realUse ResourceUtil.isNonExistingResource() after resolve(), and reserve the null check for getResource()
Copying an entire OOTB dialog to add one fieldYou freeze your copy and stop receiving Adobe's future fixes to itSling Resource Merger — add only the delta
Overlaying a Core ComponentIt doesn't work — Core Components already live under /apps, there's no /libs copy at that path to shadowUse a proxy component with sling:resourceSuperType (inheritance, not overlay)
Implementing only inbound /etc/mapClean URL in the address bar, /content/... in every generated linkAlso call resourceResolver.map(), or enable the link rewriter transformer
Confusing sling:internalRedirect with sling:redirectExpecting a silent rewrite but getting a visible HTTP redirect, or vice versaKnow which one you actually need before writing the mapping node
Anchoring sling:orderBefore against a field you're also hiding, or one Adobe might reorderField position drifts on the next Adobe update, with no errorAnchor against a stable, well-known sibling where possible, and re-verify after upgrades
Closing request.getResourceResolver() in a servlet or modelThe rest of that request breaksOnly close a resolver you opened yourself
Opening a service-user resolver without try-with-resourcesAn early return or exception skips .close(), leaking a JCR sessionAlways try-with-resources, no exceptions
Assuming vanity paths and /etc/map are the same mechanismConfusing an author-editable per-page shortcut with a developer-configured prefix ruleKnow the table in section 2.16
Not indexing for sling:vanityPath at scaleVanity path resolution slows down as content growsEnsure a proper Oak index backs the vanity path lookup
Setting up Apache/CDN rewrite rules without a matching Sling mapping (or vice versa)Browser-typed URLs work but AEM's own generated links don't, or the reverseConfigure both layers to agree, and test both directions
Assuming the Resource Merger only works via /apps -over- /libsMissing that dialog inheritance through sling:resourceSuperType is merged the same wayUnderstand both trigger paths into the same merge service

10. Best Practices

On resolution. Use getResource() for a direct, literal check where null genuinely means "not there." Use resolve() (or code paths built on it, like a servlet's request.getResource()) when you're processing something that came in as a URL, and always check isNonExistingResource() rather than a null check afterward.

On extension. Default to inheritance (sling:resourceSuperType) for extending a component's behaviour. Reach for overlay only to customise the AEM authoring UI itself — a console, a shared classic dialog — where there genuinely is no resourceSuperType relationship to lean on. Treat override as a last resort, chosen deliberately, not as a shortcut when a merge would have done the job.

On the Resource Merger. Write the smallest delta that expresses your intent — one field, one hidden property, one reordering — never a wholesale copy. Comment why a field is hidden or an order anchor was chosen, because six months later nobody remembers which Adobe field the new one was meant to replace.

On URL mapping. Treat inbound and outbound as two separate checklist items on every mapping-related story, never one. Centralise link-building through a single utility (or the link rewriter transformer) so outbound mapping isn't something every component author has to remember individually.

On vanity URLs. Use them for genuinely per-page, author-driven shortcuts — campaigns, landing pages. Use /etc/map for structural, prefix-level rewriting a developer owns. Don't use one to solve the other's problem.

On resolvers. Close what you opened, in try-with-resources, every time, with no exceptions for "this code path always returns cleanly." Never close what a framework handed you.


11. Debugging Tips

A resolution or mapping problem almost always has a fast, specific tool behind it — the trick is picking the right one first rather than guessing at OSGi config.

Tool / consoleAnswers
/system/console/servletresolver (from file 07)What actually resolves for a given URL, including the resource-type chain considered
A script console (Groovy console, or CRXDE Lite's JS console) calling resolve() / getResource() directlyConfirms exactly what a path resolves to, for a specific user's resolver, without guessing
Reading /mnt/overlay/<path> directlyShows the actual merged result the Resource Merger produces — the fastest way to confirm a dialog extension took effect
/etc/map in CRXDE LiteConfirms the exact sling:match / sling:internalRedirect values in force
Heap dump + session countThe way to confirm a suspected resolver/session leak is real before hunting for the code
dispatcher.logConfirms whether a rewritten or mapped request even reached AEM, versus being rejected earlier
Browser network tab, on both a short URL and a generated linkSeparates an inbound-mapping problem from an outbound-mapping problem immediately — one shows in the address bar, the other in the page's own links

The order that actually finds a resolution bug fast:

  1. Confirm what resolves. Use the servlet resolver console or a script console call to resolve() / getResource() directly — don't guess from reading config.
  2. Check whose resolver you're looking at. getUserID() on the resolver in question tells you immediately whether an ACL-shaped bug is actually a permissions difference between two users.
  3. For a merge problem, read the merged view directly at /mnt/overlay/<path> rather than inspecting /apps and /libs separately and trying to merge them in your head.
  4. For a mapping problem, test both directions independently — a short URL typed into the browser tests inbound only; viewing page source for /content/... links tests outbound only.
  5. For a suspected leak, get a heap dump before touching any code — confirming the session count is actually elevated saves you from "fixing" the wrong service.

12. Performance Notes

findResources() and raw queries need an index, exactly like QueryBuilder (file 01). An unindexed query through the resolver traverses the repository the same way an unindexed QueryBuilder query does — the API you called it through doesn't change Oak's underlying cost.

Vanity path resolution is built by scanning the repository for sling:vanityPath. On a large site, make sure this is backed by a proper Oak index; without one, the initial build (and any rebuild) of the internal vanity path table gets measurably slower as content grows.

The search path is checked on every resource-type-to-script lookup, per request. This is cheap individually, but it's why an unnecessarily long or oddly-ordered search path (something almost no project should ever configure) would add up across a high-traffic instance.

Every unclosed service-user resolver is a retained JCR session. This isn't a "slow" problem in the traditional sense — it's a slow-growing memory problem that eventually becomes an availability problem. Treat resolver lifecycle as a correctness issue, not a nice-to-have.

Outbound map() calls are cheap individually but add up in a loop. Building a large listing where every single row calls map() separately is fine at normal listing sizes; if you're generating links for genuinely large batches (a sitemap generator, for instance), be aware you're doing real work per link and profile it like anything else in a hot loop.


15. Comparison Tables

getResource() vs resolve()

getResource()resolve()
Applies /etc/mapNoYes
Strips selectors/extension firstNoYes
Result when nothing matchesnullNonExistingResource — never null
Applies the caller's ACLsYes (implicitly, via the resolver)Yes
Typical useDirect, known-path lookupsTurning an incoming request path into a resource

Overlay vs Inheritance vs Override

OverlayInheritanceOverride
TriggerSame relative path, /apps before /libssling:resourceSuperTypeHighest-priority resource only
ScopeGlobalScoped, opt-inScoped to the declaring resource
Mount point (Resource Merger)/mnt/overlay/mnt/overlay (chain-walked)/mnt/override
Keeps future upstream fixesNoYes, for everything not redefinedNo

/mnt/overlay vs /mnt/override

/mnt/overlay/mnt/override
BehaviourMerges everything found across the search pathPicks only the single highest-priority resource
Lower-priority contentPreserved unless explicitly hiddenNever consulted at all
Typical useAdding/removing a field, a tab, a propertyA deliberate, full replacement

sling:internalRedirect vs sling:redirect

sling:internalRedirectsling:redirect
Visible to the browserNo — silent rewriteYes — actual HTTP redirect
Address bar changesNoYes
Typical useEveryday clean-URL mappingDomain retirement, deliberate redirects

/etc/map vs sling:vanityPath

/etc/mapsling:vanityPath
Configured byDeveloperAuthor
ScopeA path prefix, a whole subtreeOne page
Needs a deploymentYesNo
Performance dependencyStatic configRepository scan — needs an index

Resolvers — who owns closing them

Resolver sourceWho closes it
request.getResourceResolver()Sling — never close it yourself
resolverFactory.getServiceResourceResolver(...)You — always try-with-resources
getAdministrativeResourceResolver()Deprecated, blocked on Cloud Service — don't use it

Next topic

24-Headless-AEM-GraphQL-and-SPA-Editor — Content Fragment-driven headless delivery through the GraphQL API, persisted queries, and how the SPA Editor keeps in-context authoring working when the front end is a React or Angular application rather than server-rendered HTL.


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