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
ResourceResolveris 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
ResourceResolverdirectly 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 callsmap()so PDFs generated for download always contain public, shortened URLs rather than internal/contentpaths. 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:
- Applies
/etc/mapinbound rewriting, so a public-facing path gets translated to its internal repository path first. - Strips selectors, extension and suffix from the incoming path before attempting to find the underlying resource.
- If, after all of that, nothing exists — it does not return
null. It returns a special object called aNonExistingResource.
Say that last part out loud, because it's the part that gets tested:
getResource()on a missing path gives younull.resolve()on a missing path never gives younull— it gives you aNonExistingResource, which is a real, non-nullResourceobject 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:
The interview answer:
"
getResource()is a direct lookup — you give it a path, and you get either the resource ornull. 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/mapinbound rewriting first, strips selectors and extension, and — this is the part people miss — it never returnsnull. If nothing matches, it gives you back aNonExistingResource, 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 writeif (resolver.resolve(path) == null), that branch is dead code — I need to checkResourceUtil.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.
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.
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:
- Day one: nothing looks wrong. The instance has plenty of headroom.
- 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.
- 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:resourceTypesays what this resource is. It's the starting point of script resolution.sling:resourceSuperTypesays 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:
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
/appsas 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 issling: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/overrideas 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
/appsappears 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/overlaycombines what it finds across the search path — your addition plus everything from/libsyou didn't touch./mnt/overridepicks 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.
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 shapehost[.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.
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/maphandles it, and the mistake most people make is only implementing one direction. Inbound, asling:Mappingnode'ssling:matchandsling:internalRedirectproperties 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 callresourceResolver.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."
2.13 map() and the link rewriter — the mechanics of the outbound half
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.
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:
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
8.2 A service-user resolver, opened and closed correctly
8.3 A link-building utility that always calls map() — the fix from Story 1
8.4 /etc/map — the inbound mapping node
8.5 A vanity URL on a campaign page
8.6 The Resource Merger — extending page properties (full worked example)
8.7 The Resource Merger — extending a Core Component's dialog via a proxy (ties to file 02)
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
9. Common Mistakes
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.
The order that actually finds a resolution bug fast:
- Confirm what resolves. Use the servlet resolver console or a script console call to
resolve()/getResource()directly — don't guess from reading config. - 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. - For a merge problem, read the merged view directly at
/mnt/overlay/<path>rather than inspecting/appsand/libsseparately and trying to merge them in your head. - 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. - 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()
Overlay vs Inheritance vs Override
/mnt/overlay vs /mnt/override
sling:internalRedirect vs sling:redirect
/etc/map vs sling:vanityPath
Resolvers — who owns closing them
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.