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/jcr-oak-and-the-repository.md.

JCR, Oak and the Repository

20 – JCR, Oak and the Repository

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 — this is the floor everything else stands on

Every file in this repository so far has quietly assumed this one.

File 02 built components — those are nodes. File 03 built templates and policies — those are nodes under /conf. File 05's Sling Models read properties — those are JCR properties. File 13's permissions are stored as nodes. File 18's replication moves nodes between instances. Everything in AEM is the repository. There is no second place where things live.

So this file is the floor. And it has a particular character in interviews that is worth naming up front.

Interviewers use this topic to separate two kinds of candidate. Someone who has only authored and assembled will describe the repository as "the tree in CRXDE." Someone who has actually operated AEM will tell you why a page has a jcr:content child, why binaries are not stored with the nodes, and what happens to disk usage when nobody runs revision cleanup. Your four years of support experience is a real advantage here — you have almost certainly seen a disk fill up or a repository grow strangely, and that is exactly the kind of story this topic rewards.

One warning before we start, and please take it seriously.

This topic is full of near-misses — facts that are almost right. "AEMaaCS uses MongoDB" is a near-miss. "Oak is Jackrabbit version 3" is a near-miss. "Binaries are stored in the node store" is a near-miss. A near-miss is worse than an honest "I'd have to check," because it sounds confident and it is wrong, and an interviewer who knows Oak will hear it instantly.

So throughout this file, where a fact is precise, it is stated precisely and marked. Where the honest answer is "the mechanism works like this, and the exact naming varies," it is written that way deliberately. Say the mechanism, not a name you half-remember. That is a stronger answer, not a weaker one.


1. Introduction

1.1 What problem does a content repository solve?

Start with the alternative, because the improvement is the point.

Imagine building a CMS on a normal relational database. You need a pages table. Pages have titles, so a title column. Then marketing wants a subtitle on some pages — add a column, nullable. Then product pages need a datasheet reference, but news pages need a publication date, and event pages need a venue. Six months in, your pages table has ninety columns, eighty of which are null on any given row.

Then someone asks for a page hierarchy. In SQL that is a parent_id self-join, and "give me every page under /products/transformers " becomes a recursive query. Then someone asks for version history, and you build a page_versions table and copy rows into it. Then someone asks who is allowed to edit which branch, and you build a permissions table keyed by path prefix.

Every one of those problems is solved once, in the specification, by JCR.

  • Content is a tree, natively. Hierarchy is not a join, it is the storage model.
  • Nodes are schema-flexible. A node can have whatever properties it needs, and two sibling nodes can have completely different ones.
  • Versioning, observation and access control are in the specification — not bolted on by each application.

That is what a content repository is: a database whose data model is a tree of nodes with arbitrary properties, with versioning, events and security built in.

1.2 JCR — the specification

JCR stands for Java Content Repository. It is a Java specification, not a product.

  • JSR-170 was JCR 1.0 (2005).
  • JSR-283 is JCR 2.0 (2009), and this is the one AEM implements.

The API lives in the javax.jcr package: Repository, Session, Node, Property, Value, Workspace, QueryManager. If you have ever written node.getProperty("jcr:title").getString(), you have used it.

Say this precisely in an interview, because the version numbers are a cheap credibility point:

"JCR is the Java Content Repository specification — JSR-283 for JCR 2.0, which is the version AEM implements. It defines content as a hierarchy of nodes and properties, and it puts versioning, observation and access control into the specification itself rather than leaving each application to invent them."

1.3 Oak — the implementation, and why it replaced classic Jackrabbit

A specification needs an implementation. AEM's is Apache Jackrabbit Oak, usually just called Oak.

The naming trips people up, so get this right. Apache Jackrabbit is the older project — often called "classic Jackrabbit" or Jackrabbit 2.x. Oak is a separate, newer project under the same Apache Jackrabbit umbrella. It is not "Jackrabbit version 3." Saying that is a near-miss an interviewer will catch.

Which AEM versions use which:

ProductRepository implementation
CQ 5.xClassic Jackrabbit 2.x
AEM 6.0 onwardsApache Jackrabbit Oak
AEM 6.5Oak
AEM as a Cloud ServiceOak

Now the interesting part — why did Adobe move? This is the actual question, and there are three reasons worth knowing.

Reason one — clustering. Classic Jackrabbit was built around a single repository instance owning its storage. If you wanted several author instances sharing one set of content, you were fighting the design. Oak was built from the start with a pluggable storage layer, and one of those plug-ins stores content in a shared database that several instances can use simultaneously. That is what makes a clustered author tier possible — and it is exactly what AEM as a Cloud Service needs.

Reason two — scale. Enterprise repositories grew from thousands of nodes to tens of millions. Classic Jackrabbit's design assumed more of the working set would be in memory and used coarser locking. Oak uses MVCC — multi-version concurrency control — so readers never block writers and writers never block readers. Each session sees a consistent snapshot of the tree.

Reason three — a proper query engine with pluggable indexes. Oak treats indexing as a first-class, pluggable concern with a cost-based query planner. That is the whole of file 21.

The consequence of MVCC that you will actually meet. Because writes are optimistic rather than lock-based, two sessions changing the same node concurrently do not queue up — the second one fails at commit time with a CommitFailedException about unresolved conflicts. This is why concurrent writes to the same node (a hit counter on a page node, for instance) are a genuinely bad pattern in AEM, and why heavy write workloads get pushed into Sling Jobs (file 10) instead of happening inline.

The interview answer — about 45 seconds:

"AEM uses Apache Jackrabbit Oak, which implements JCR 2.0. Oak replaced classic Jackrabbit from AEM 6.0 onwards — and it's worth being precise that Oak is a separate project, not Jackrabbit version 3.

The move happened for three reasons. Clustering: Oak has a pluggable storage layer, and one of those options lets several instances share one repository, which is what a clustered author tier needs. Scale: Oak uses MVCC, so readers and writers don't block each other, which matters at tens of millions of nodes. And a proper cost-based query engine with pluggable indexes.

The MVCC part has a practical consequence — concurrent writes to the same node don't queue, they fail at commit with a conflict. So write-heavy patterns like counters on a content node are something we deliberately avoid."

1.4 Where this shows up in your daily work

You touch the repository constantly without thinking about it:

  • Every dialog you build writes properties on a node.
  • Every Sling Model you write reads properties from a node (file 05).
  • Every sling:resourceType is a string property that Sling resolves into a script (file 01).
  • Every permission you grant is stored as nodes under an access control list (file 13).
  • Every activation copies a node subtree from author to publish (file 18).
  • Every clientlib is a node of a specific primary type (file 04).

The repository is not a topic that sits beside AEM development. It is AEM development, seen from underneath.

1.5 A real project example to adapt

"On our energy site the repository is the thing I ended up understanding best, mostly because of incidents. We run AEM as a Cloud Service — author is a clustered tier on a shared document store, publish is a set of pods each with its own local segment store, which is why publish scales out by adding pods and author doesn't. Binaries go to a separate blob store, so a two-hundred-megabyte datasheet is stored once and referenced by every page version that points at it rather than being copied per version.

The two things I've personally dealt with are repository growth and structure. We had a migration that created about forty thousand asset nodes as direct children of one folder, and everything touching that folder got slow — that taught me the flat-structure problem properly. And on the 6.5 environment we inherited before the cloud move, disk usage was climbing steadily because revision cleanup and data store garbage collection weren't running in the maintenance window. Both of those are things I can walk through end to end."

That paragraph pre-empts four follow-ups — node stores, blob stores, flat structures and maintenance — and every one of them is a topic in this file.


2. Core Concepts

2.1 Nodes and properties — the two things that exist

The data model is genuinely this small. There are nodes and there are properties.

A node is a point in the tree. It has:

  • a name (the segment in the path — en, jcr:content, teaser)
  • a path (/content/energy/global/en/jcr:content/root/teaser)
  • a primary type — exactly one, stored in the jcr:primaryType property
  • optionally mixin types — zero or more, stored in jcr:mixinTypes
  • child nodes and properties

A property is a named value attached to a node. It has a type, and JCR defines the set: String, Long, Double, Decimal, Boolean, Date, Binary, Name, Path, Reference, WeakReference, URI. A property can be single-valued or multi-valued (an array).

A detail that catches people in interviews: JCR has Long, not Integer. When your dialog stores a number, it lands in the repository as a Long. That is why in file 05 you inject Integer maxItems and Sling does the conversion for you — the underlying property is a Long.

And the one about dates: a JCR Date maps to java.util.Calendar in Java, not Date. That is why @ValueMapValue private Calendar publishDate; is the right shape.

Here is an actual piece of our site:

/content/energy/global/en/products/transformers          ← node, type cq:Page
  ├── jcr:primaryType = "cq:Page"                        ← property, type Name
  └── jcr:content                                        ← node, type cq:PageContent
       ├── jcr:primaryType = "cq:PageContent"
       ├── jcr:title = "Power Transformers"              ← property, String
       ├── jcr:created = 2024-03-11T09:14:22.108+05:30   ← property, Date
       ├── sling:resourceType = "energy/components/page" ← property, String
       ├── cq:template = "/conf/energy/settings/wcm/templates/product-page"
       ├── cq:tags = ["energy:products/transformers",    ← property, MULTI-VALUE
       │              "energy:segments/utilities"]
       └── root                                          ← node, nt:unstructured
            └── teaser                                   ← node, nt:unstructured
                 ├── sling:resourceType = "energy/components/teaser"
                 └── jcr:title = "Built for the grid"

Read that structure carefully — there is a lot in it. The page's own node holds almost nothing. All the content is on jcr:content. The components are nodes under jcr:content/root. And sling:resourceType — the single most important property in AEM — is just a plain String.

2.2 Namespaces — reading a property name

The prefix before the colon is a namespace, and it tells you who defined that name. Being able to decode a prefix on sight is a small thing that signals you have actually read the repository rather than only clicked around it.

PrefixWho defined itExamples
jcr:The JCR specification itselfjcr:primaryType, jcr:content, jcr:title, jcr:created, jcr:data, jcr:uuid
nt:JCR's built-in node typesnt:unstructured, nt:folder, nt:file, nt:resource, nt:base
mix:JCR's built-in mixin typesmix:versionable, mix:referenceable, mix:lockable, mix:title
sling:Apache Slingsling:resourceType, sling:resourceSuperType, sling:Folder, sling:OrderedFolder
cq:AEM itself — CQ was the old product name, Day Communiquécq:Page, cq:PageContent, cq:Component, cq:Template, cq:template, cq:tags
dam:AEM Assetsdam:Asset, dam:AssetContent
rep:Oak — mostly securityrep:User, rep:Group, rep:SystemUser, rep:policy, rep:principalName
oak:Oak internalsoak:index, oak:QueryIndexDefinition
granite:Adobe's Granite platform layerauthoring UI internals

Two things to notice, because interviewers ask both.

jcr: versus cq: matters. jcr:title is a specification-defined name that any JCR repository understands. cq:template is Adobe's own. When you say "this is a cq: property, so it's AEM-specific rather than standard JCR," you have shown you know the difference between the platform and the specification.

rep: and oak: are Oak's own, and you generally do not create them. If you find yourself writing a rep: property by hand, stop — you are almost certainly meant to be using the access control API instead (file 13).

Why a Java field can't be called jcr:title. A colon is not legal in a Java identifier. That is the entire reason @Named("jcr:title") exists in file 05:

@ValueMapValue
@Named("jcr:title")     // property name has a colon; the field can't
private String title;

2.3 Primary types and mixins

Every node has exactly one primary type. It is stored in jcr:primaryType and it defines what that node is allowed to contain — which properties, which child nodes, and whether children keep an order.

A node can also have zero or more mixins. A mixin is an optional add-on type that grants extra capabilities. They are stored as a multi-value jcr:mixinTypes property.

The analogy that actually helps: primary type is your job title, mixins are certifications you have added. You have exactly one job title. You can hold several certifications, and each one lets you do something extra.

The mixins that matter in AEM:

MixinWhat it grantsWhere you meet it
mix:versionableThe node can be checked in and out; versions are keptOn cq:PageContent — this is how page versioning works
mix:referenceableThe node gets a stable jcr:uuid, so other nodes can reference itAssets, tags, anything referenced by identity rather than path
mix:lockableThe node can be lockedPage locking in the author UI
mix:created / mix:lastModifiedAdds the created/modified property pairsVarious content types

The one that carries real weight is mix:versionable, and here is the fact worth carrying into an interview: in AEM it is the jcr:content node of a page that is versionable, not the page node itself. cq:PageContent includes mix:versionable. That is why a page version restores the page's content while the page keeps its place, its name and its URL. It is the same reasoning as the next section, and the two together make one strong answer.

2.4 The node types that actually matter

You do not need to memorise the whole node type registry. You need to be able to say what these are for, because that is how the question gets asked.

Content types:

Node typeWhat it is forWhere
nt:unstructuredAccepts any property and any child node. No constraints, and children keep their order.Every component instance on a page; dialog definitions
cq:PageMarks a node as an AEM pageEverything under /content/<site>
cq:PageContentThe jcr:content child of a page — versionableInside every page
dam:AssetMarks a node as a DAM assetUnder /content/dam
nt:fileA file — its content lives on a jcr:content child of type nt:resourceRenditions, uploaded files
nt:resourceHolds the actual bytes in a jcr:data binary property, plus jcr:mimeTypeInside every nt:file

Code and configuration types:

Node typeWhat it is forWhere
cq:ComponentA component definition/apps/<project>/components/...
cq:TemplateA template/conf/<project>/settings/wcm/templates/...
cq:ClientLibraryFolderA clientlib — CSS/JS bundle (file 04)/apps/<project>/clientlibs/...
sling:FolderA folder that can hold arbitrary propertiesConfig folders, /var structures
sling:OrderedFolderA sling:Folder whose children keep their orderWhere sequence matters
nt:folderA plain folder — strict, no arbitrary propertiesGeneric folders, /content/dam folders

Security types:

Node typeWhat it is for
rep:UserA user under /home/users
rep:SystemUserA service user — no password, created for code (file 13)
rep:GroupA group under /home/groups
rep:ACL, rep:GrantACE, rep:DenyACEThe stored access control entries under a rep:policy node

Index types:

Node typeWhat it is for
oak:QueryIndexDefinitionAn Oak index definition under /oak:index — all of file 21

2.5 nt:folder versus sling:Folder — the constraint-violation trap

This one deserves its own section because it produces an error message that tells you almost nothing, and because it is a favourite interview question for exactly that reason.

nt:folder is strict. It is defined to hold hierarchy nodes — other folders and files — and it does not permit arbitrary properties. Try to write your own property onto an nt:folder node and the commit fails with a constraint violation.

sling:Folder is permissive. Sling defined it precisely because the strictness of nt:folder was painful. It accepts any property and any child node.

What this looks like in practice. You write a small piece of code that stores a marker on a folder:

Resource folder = resolver.getResource("/content/dam/energy/datasheets");
ModifiableValueMap map = folder.adaptTo(ModifiableValueMap.class);
map.put("lastSyncedAt", Calendar.getInstance());   // BOOM on an nt:folder
resolver.commit();

The commit throws a constraint violation, and the message names a node type rather than saying "you can't put properties here." Developers lose an hour to this.

The fix is one of two things. Either change the folder's primary type to sling:Folder (which is what you want for a folder you own), or — better in DAM, where you should not be fighting the asset structure — store the marker on a child node of type nt:unstructured instead of on the folder itself.

A related gotcha in the same family: if you create folders through the Assets UI you get one type, and if you create them through CRXDE by picking a type yourself you get another. Mixed folder types under /content/dam is a real source of "this works in one folder and not the other."

The interview answer — about 30 seconds:

" nt:folder is a strict node type — it's defined to hold files and other folders and it won't accept arbitrary properties. sling:Folder is Sling's permissive version, which accepts any property and any child.

The reason it matters practically is the error you get: if code writes a property onto an nt:folder node, the commit fails with a constraint violation, and the message names a node type rather than explaining the actual problem. I've seen an hour lost to that. So if a folder needs to carry properties, it should be sling:Folder, or the properties should live on an nt:unstructured child rather than on the folder itself."

2.6 The cq:Page / jcr:content split — and why it exists

This is the single most important structural fact in AEM, and interviewers use it as a filter. Almost everyone can say that pages have a jcr:content. Far fewer can say why.

The structure:

/content/energy/global/en/products          ← cq:Page      — the ENVELOPE
  ├── jcr:content                           ← cq:PageContent — the LETTER
  │    ├── jcr:title = "Products"
  │    ├── cq:template = "..."
  │    ├── sling:resourceType = "..."
  │    ├── cq:lastModified, cq:lastModifiedBy
  │    └── root/  (the components)
  ├── transformers                          ← a child cq:Page
  ├── hvdc-systems                          ← a child cq:Page
  └── grid-automation                       ← a child cq:Page

The principle: the cq:Page node is the page's identity. Its name is the URL segment, its position in the tree is the site structure, and its children are the child pages. The jcr:content node is the page's content. Everything an author can change lives there.

Now — the four reasons this separation is necessary. Give two of them and you have answered well; give all four and you have answered better than most people with more experience.

Reason one — versioning. A version captures the jcr:content subtree, because cq:PageContent is the versionable node. Restoring a version replaces the content while the page stays exactly where it is, keeps its name, keeps its URL and keeps its children. If content lived on the page node, restoring a version would mean restoring the node that also holds all the child pages — which is a completely different and much more dangerous operation.

Reason two — child pages are not content. Look at the tree above. transformers is a sibling of jcr:content, not inside it. That means "the pages below this page" and "the content of this page" are two cleanly separated subtrees. Deleting all a page's content does not touch its children.

Reason three — publishing and MSM. Replication and live copies operate on subtrees. Because content is one clean subtree, you can roll out content changes to a live copy without touching the live copy's page structure, and vice versa. File 12's rollout configurations depend on exactly this separation.

Reason four — the URL. /content/energy/global/en/products.html resolves to the page node. Sling then reads jcr:content 's sling:resourceType to find the rendering script (file 01). If content and identity were the same node, the resource type would have to sit on the node that also defines the URL structure — which is a much messier contract.

The line to remember, and it is genuinely memorable:

cq:Page is the envelope. jcr:content is the letter inside it. You can replace the letter without moving the envelope.

Two practical consequences a developer meets constantly:

page.getPath() gives you /content/.../products, but page.getContentResource().getPath() gives you /content/.../products/jcr:content. Mixing those up is the cause of a great many "my property is null" bugs, because you read the property from the page node where it does not exist.

And in file 05, currentPage.getProperties() reads from jcr:content, which is what you want — but currentPage.adaptTo(Resource.class).getValueMap() reads from the page node, which is nearly empty. That distinction is worth internalising.

2.7 The repository tree — where everything lives and why

The tree is not arbitrary. Each top-level branch has a rule about who writes to it, and that rule is the actual content of the answer.

/apps — your code. Components, template types, clientlibs, servlet scripts, your OSGi configurations, and your Oak index definitions. It arrives through a deployment, not through a UI. Nobody edits it by hand on a running production instance.

/libs — Adobe's code. Every out-of-the-box component and every piece of the authoring UI. Never edit /libs. Not once. A service pack or an upgrade replaces /libs wholesale and your change vanishes silently. If you need to change something there, you overlay it — copy it to the same relative path under /apps.

/content — authored content. Pages under /content/<site>, assets under /content/dam, experience fragments under /content/experience-fragments, tags under /content/cq:tags, and launches under /content/launches. Authors write here; developers generally do not.

/conf — configuration that belongs to a site or brand. Editable templates and their policies (file 03), Content Fragment models (file 15), context-aware configuration, and workflow models. The distinguishing question: does this differ per brand or site? Then /conf. Does it differ per environment? Then an OSGi configuration in /apps (file 06).

/var — runtime data AEM generates as it runs. Workflow instances, audit logs, event data, statistics, and various caches. This is the branch that grows, and it is the first place to look when a repository is unexpectedly large. It is also the branch that maintenance tasks exist to prune.

/home — users and groups. /home/users and /home/groups, including /home/users/system for service users (file 13).

/etc — the legacy area. In older AEM this held a great deal; most of it moved out in the 6.4/6.5 repository restructuring (section 2.13). What remains in practice is /etc/packages and /etc/map for URL mappings.

/oak:index — Oak's index definitions. All of file 21 lives here.

/tmp — scratch space. A genuine repository path meant for transient content. Things written here are not meant to survive, and it gets cleaned. Do not put anything you care about in it.

/jcr:system — the specification's own storage. Version history lives under /jcr:system/rep:versionStorage, along with the node type registry, namespace registry and the permission store. You read it occasionally when debugging versions; you never write to it directly.

Two paths that are not really nodes. /system (the Felix console and health checks) and /bin (servlet endpoints) are virtual — they are served by servlets, not stored as content. Looking for /bin/energy/cards in CRXDE and not finding it is expected behaviour, not a bug. This surprises people from file 07.

2.8 Immutable versus mutable — the AEMaaCS distinction

On AEM 6.5 the whole repository is writable at runtime. Someone with admin rights can open CRXDE on production and change /apps. It is a bad idea, but it is possible.

On AEM as a Cloud Service, that possibility is removed by design, and the repository is split in two.

Immutable content: /apps and /libs. These are built into the container image at deployment time and are read-only at runtime. You cannot write to them, no matter who you are. There is no CRXDE-on-production escape hatch.

Mutable content: everything else — /content, /conf, /var, /home, /etc, /oak:index, /tmp. This is the actual live data of the environment and it persists across deployments.

Why this matters far more than it first appears — three consequences that come up constantly:

One — configuration must be in code. An OSGi config typed into the web console on 6.5 survives until someone restarts. On AEMaaCS it cannot even be typed in, because it belongs under /apps. Everything is a file in the build (file 14).

Two — permissions and service users must be created by Repoinit. You cannot install-hook them, and you cannot create them by hand and expect them to survive, because pods are recreated from the image. Repoinit statements run at startup and put them into the mutable area (file 13).

Three — this is the reason for the ui.apps / ui.content package split in the Maven project. ui.apps carries immutable content and is deployed with the image; ui.content carries mutable content and is installed into the running repository. Mixing them up — putting a page under ui.apps, or a component under ui.content — is a build failure or a deployment surprise, and it is one of the checks Adobe's build-time analysis catches.

Where index definitions sit in this picture, precisely. Custom Oak index definitions are authored in the project under ui.apps and ship as part of the immutable build, even though they land at /oak:index, which is a mutable path. Practically: you treat index definitions as code, you version them in git, and you never hand-edit them on a cloud environment. File 21 covers the naming convention that makes this work.

2.9 NodeStore — SegmentNodeStore versus DocumentNodeStore

This is the highest-value section in the file, and it is also where the classic near-miss lives. Read it twice.

The concept first. Oak separates "what the content tree looks like" from "how the bytes are persisted." The layer that persists the tree is called the NodeStore, and Oak has two production implementations.

SegmentNodeStore — commonly called TarMK.

It writes the content tree into segments packed inside .tar files on local disk. Reads are local file reads, so it is fast. But it is files on one machine's disk, and exactly one AEM instance can own a given segment store at a time. You cannot point two instances at it and expect them to share.

DocumentNodeStore — commonly called MongoMK when the backend is MongoDB.

It stores the content tree as documents in a database, most commonly MongoDB. (There is also an RDB variant backed by a relational database, which you will rarely meet in practice.) Every read may cross the network, so it is slower than local disk. In exchange, several AEM instances can share the same store — which is what makes clustering possible.

The trade-off in one line:

Segment is faster. Document can cluster. You pick based on whether you need more than one instance sharing the content.

Now — the near-miss. Read this paragraph slowly.

On AEM as a Cloud Service:

  • Author uses DocumentNodeStore, on MongoDB. The author tier is a cluster — several instances serving the same set of authors, all seeing the same content. That requires a shared store.
  • Publish uses SegmentNodeStore. Each publish pod holds its own local copy of the content. Pods are essentially read-only from the application's point of view — content arrives by distribution from author (file 18) — so the right optimisation is raw local speed, not sharing.

The wrong answers you will hear, and might say by accident:

Near-missWhy it's wrong
"AEMaaCS uses MongoDB"Only for author. Publish is Segment.
"AEMaaCS publish is clustered on Mongo"Publish pods scale out, but each has its own segment store. That is not a repository cluster.
"Author uses TarMK because it's faster"Author needs sharing, and TarMK cannot share.
"TarMK supports clustering"It does not. One instance per segment store.

Why publish being Segment is actually elegant. Publish pods auto-scale — under load, more pods appear. If they all shared one repository, that repository would become the bottleneck and adding pods would help less and less. Because each pod has its own local copy and content is pushed to it, adding a pod adds capacity almost linearly. Scaling out and sharing state pull in opposite directions, and Adobe chose not to share on the tier that needs to scale.

On AEM 6.5 on-premise, the picture is simpler: most installations use TarMK on both author and publish, and a clustered author using MongoMK exists but is used far less often than people assume. If asked about 6.5, "TarMK by default on both tiers; MongoMK only where a clustered author was genuinely required" is the accurate answer.

One more thing worth knowing about 6.5, because it gets confused with clustering. TarMK Cold Standby is a disaster-recovery arrangement where a standby instance continuously receives segment data from a primary. It is not a cluster — the standby is not serving traffic and not accepting writes. Calling it clustering is another near-miss.

The interview answer — about 75 seconds:

"Oak separates the content tree from how it's persisted, and that persistence layer is the NodeStore. There are two production implementations.

SegmentNodeStore — TarMK — writes segments into tar files on local disk. It's the fastest option because reads are local, but only one instance can own a given segment store, so it can't be shared.

DocumentNodeStore — MongoMK when the backend is MongoDB — stores nodes as documents in a shared database. Every read may cross the network so it's slower, but multiple instances can share it, which is what makes clustering possible.

On AEM as a Cloud Service the split is specific and worth getting exactly right: author uses Document on MongoDB, because the author tier is clustered and all authors must see the same content. Publish uses Segment, because each publish pod holds its own local copy and content arrives by distribution.

The reason that's the right design is that publish auto-scales. If every pod shared one repository, the repository would become the bottleneck and adding pods would stop helping. Because each pod is independent, capacity scales almost linearly.

On 6.5 on-premise it's usually TarMK on both tiers, with MongoMK only where a clustered author was genuinely needed."

2.10 BlobStore / DataStore — where binaries actually go

Start with the problem, because the design only makes sense once you feel the pain it avoids.

Our energy site has product datasheets. A high-resolution transformer datasheet PDF is 40 MB. The site has thousands of them, plus product photography, plus video.

Now: someone edits the page that links to that datasheet, and AEM creates a version. If the binary were stored inside the node tree, versioning that subtree would mean duplicating 40 MB. Do that on every edit, across thousands of assets, and the repository grows without bound while the actual content barely changes.

So Oak stores binaries separately. The node tree holds the structure and the small values; large binary values are written to a separate store and the node holds only a reference to them.

The two names, and what they mean. You will hear both BlobStore and DataStore, and people use them loosely. The honest, safe framing:

"Binaries are held outside the node tree, in a separate binary store. Oak has more than one implementation of that — a file-based one, and cloud-backed ones for S3 and Azure Blob. The node tree keeps a reference, not the bytes."

That is accurate and it sidesteps the internal naming, which genuinely varies between Oak versions and AEM versions. This is exactly the kind of place where being slightly less specific is the stronger answer.

The mechanism that makes it powerful — content addressing.

Binaries are identified by a cryptographic hash of their content. That gives you two things for free:

Deduplication. Upload the same PDF twice, into two different DAM folders, and there is one copy of the bytes with two nodes referencing it. Marketing teams do this constantly — the same brochure lands in three campaign folders — and it costs almost nothing.

Cheap versioning and cheap copies. Version a page a hundred times, and every version references the same binary. Create an MSM live copy of a whole country site (file 12), and the copied nodes reference the same binaries. Copying a subtree does not copy binaries.

graph TD A["Node tree<br/>(NodeStore)<br/>structure + small values"] -->|"holds a reference"| B["Binary store<br/>identified by content hash"] C["/content/dam/.../datasheet.pdf<br/>version 1"] --> B D["/content/dam/.../datasheet.pdf<br/>version 2 (metadata edit)"] --> B E["Live copy in the DE site"] --> B F["The same PDF in a campaign folder"] --> B B --> G["ONE copy of the 40 MB<br/>on disk or in cloud storage"]

Where the binary store actually lives:

SetupBinary storage
AEM 6.5, single instanceA file data store on local disk
AEM 6.5, clustered or sharedA shared file data store, or S3 / Azure Blob
AEM as a Cloud ServiceCloud object storage, managed by Adobe

The consequence that produces incidents — and this is a genuinely good story to be able to tell. Because deletion is decoupled from the node tree, deleting an asset does not immediately free the disk space. The node goes; the blob stays until garbage collection runs and confirms that nothing references it any more. On a 6.5 environment where data store GC is not running, you can delete gigabytes of assets and watch disk usage not move at all. Section 2.12 covers the maintenance side.

Direct binary upload — worth one sentence for AEMaaCS. On the cloud service, large asset uploads go from the client straight to cloud storage, with AEM issuing the upload URL and being told when it completes, rather than the bytes streaming through the AEM JVM. That is why asset ingestion behaves differently there and why the old "upload times out through AEM" problem largely went away.

2.11 Sessions, ResourceResolver, and how they leak

Now the API layer — because this is where developers actually cause repository problems.

A Session (javax.jcr.Session) is a JCR login. It represents one user's connection to the repository, it carries that user's permissions, and — because of MVCC — it sees a consistent snapshot of the content from when it was opened.

A ResourceResolver is Sling's abstraction on top of it. It is what you actually use in AEM code, and it does more than JCR: it applies resource type resolution, resource mappings (short URLs), and it can resolve resources that are not JCR-backed at all.

The relationship, precisely:

// A ResourceResolver WRAPS a JCR Session. You can reach through:
Session session = resourceResolver.adaptTo(Session.class);

And the rule that matters most: that session belongs to the resolver. Do not log it out. Closing the resolver closes the session. Logging the session out from under the resolver breaks the resolver in a way that is very confusing to debug.

Which API to use, and the honest answer for an interview:

"I work with ResourceResolver almost always. It's the Sling-level abstraction, it gives me resource types and mappings, it's what Sling Models and every AEM API expect, and it doesn't force me to handle RepositoryException on every read.

I drop to the JCR Session only for things that genuinely aren't in the Sling API — versioning operations, the node type registry, JCR observation, or some access control work. And even then I get the session by adapting the resolver rather than opening a second one."

Now the leak, because this is the production issue.

Each session and each resolver holds repository state — a snapshot, memory, and a slot in the repository's accounting. If you open one and never close it, that state is never released. Do it inside a request or a scheduled job, and you leak once per execution. That compounds until the instance runs out of memory.

The ownership rule, which is file 13's rule and worth repeating because it is inverted from what people expect:

Close what you opened. Never close what Sling gave you.

// You OPENED this. You close it. try-with-resources does it for you.
try (ResourceResolver resolver =
         resolverFactory.getServiceResourceResolver(AUTH_INFO)) {
    Resource r = resolver.getResource("/content/energy/global/en");
    // ... work ...
}   // closed automatically, even if something throws

// Sling opened this one and Sling will close it at the end of the request.
// Closing it yourself BREAKS the rest of the request.
ResourceResolver requestResolver = request.getResourceResolver();

The stale-snapshot bug, which is subtler and worth knowing. Because a session sees a snapshot, a long-lived resolver — one held in a field of an OSGi service and reused for hours — gradually goes stale. It keeps returning content as it was when it was opened. The symptom is "my scheduled job doesn't see content that was published an hour ago," and the cause is a resolver that should have been opened per execution and closed. session.refresh(true) exists, but the right fix is almost always a shorter-lived resolver.

How to see leaks: the JMX console at /system/console/jmx exposes repository session information. A session count that climbs steadily and never comes back down is the signature. If it climbs in step with a scheduled job's interval, you have found the culprit.

2.12 Repository maintenance on 6.5 — and why a disk fills up

This is where your support background is worth the most. These are operational facts, and being able to state them confidently sets you apart from developers who have only ever worked on a laptop instance.

The underlying reason maintenance is needed at all: Oak does not overwrite data in place. Because of MVCC, a change writes new data and leaves the old revision in place so that sessions holding an older snapshot still work. Old revisions become garbage once nothing needs them — but garbage is not reclaimed automatically as it becomes garbage. Something has to sweep it up.

Two different sweeps, for two different stores. Do not confuse them — interviewers do ask.

Revision garbage collection (also called compaction) — cleans the NODE store.

On TarMK, old revisions accumulate inside the tar files. Revision GC works out what is still reachable, rewrites the live data compactly, and removes the old segments. Without it, the tar files grow steadily even when the content is not growing. On a busy author instance this is dramatic — a repository can be several times larger than its actual content.

It runs in phases — estimation, compaction, and clean-up — and it is designed to run online, in a maintenance window, while the instance is up. There is also an offline compaction using the oak-run tool, which is more thorough but requires the instance to be stopped, and is what you reach for when online cleanup has fallen too far behind.

DocumentNodeStore has its own equivalent revision garbage collection, removing superseded revisions from the backing database.

Data store garbage collection — cleans the BINARY store.

This is the one that explains "we deleted 200 GB of assets and disk usage didn't change." Deleting an asset removes the node; the blob is only removed once GC confirms nothing references it.

It works as mark and sweep — first mark everything still referenced, then sweep away what was not marked. The critical operational detail: if several instances share one data store, you must run the mark phase on every instance that references it before running the sweep. Sweeping based on only one instance's references would delete blobs that another instance still uses. That is a genuinely bad day, and knowing this rule marks you as someone who has operated AEM rather than only developed on it.

Where you configure this on 6.5. The Operations Dashboard, at /libs/granite/operations/content/maintenance.html, holds maintenance windows — typically a daily one and a weekly one — with tasks assigned to them. The heavier repository tasks (revision clean-up, data store GC, Lucene binaries cleanup) sit in the weekly window; the content-pruning tasks (workflow purge, audit log maintenance, version purge) sit in the daily window. The exact defaults vary by version and by what the previous team configured, so the honest answer is "I check the Operations Dashboard to see which tasks are actually enabled and when they last succeeded."

The other tasks that keep /var under control, and which cause slow bloat when they are off:

TaskWhat it prunesSymptom when it isn't running
Workflow purgeCompleted workflow instances under /var/workflow/var grows continuously; workflow console gets slow
Audit log maintenanceAudit entries under /var/audit/var grows; page properties dialogs slow down
Version purgeOld page versions in version storageRepository grows; version history unmanageable
Lucene binaries cleanupOld index binariesDisk grows even with stable content

On AEM as a Cloud Service, all of this is Adobe's problem. Revision cleanup, data store GC and index maintenance are managed by the platform. You do not configure maintenance windows on AEMaaCS — and saying so, rather than describing a maintenance dashboard that is not there, is the accurate answer.

But do not overstate it. What is still yours on the cloud service: not creating unbounded content (a job that writes a node per run into one folder forever will still cause you a problem), keeping workflow and version growth sane, and structuring content so it does not go flat.

The interview answer — about 60 seconds:

"Oak doesn't overwrite in place — because of MVCC, a change writes new data and leaves the old revision behind so existing sessions stay consistent. So garbage accumulates and something has to reclaim it.

There are two separate collections and it's worth keeping them apart. Revision garbage collection, or compaction, cleans the node store — on TarMK it rewrites the live data compactly and drops the old segments. Without it, tar files grow even when content doesn't. Data store garbage collection cleans the binary store, and it's mark-and-sweep. That's the one that explains the classic incident where you delete a lot of assets and disk usage doesn't move — the nodes are gone but the blobs aren't reclaimed until GC runs.

The operational detail I'd flag: if several instances share one data store, you have to run the mark phase on all of them before sweeping, or you'll delete blobs another instance still references.

On 6.5 these are maintenance tasks in the Operations Dashboard, and the first thing I check on a repository-growth ticket is whether they're actually enabled and when they last succeeded. On AEM as a Cloud Service Adobe manages all of it — there's no maintenance window for me to configure."

2.13 Observation — how AEM knows something changed

The repository can tell you when it changes. This is the mechanism behind a great deal of AEM, and it is also a classic source of self-inflicted performance problems, which ties directly to file 10.

Two APIs, and one is clearly preferred:

JCR observation (javax.jcr.observation.EventListener) is the specification-level API. You register for event types — node added, node removed, property changed — under a path, and Oak calls you.

Sling's ResourceChangeListener is the modern, recommended API in AEM. You declare paths and change types as OSGi component properties, and you receive resource-level changes. File 10 has the full treatment:

@Component(
    service = ResourceChangeListener.class,
    property = {
        ResourceChangeListener.PATHS + "=/content/energy/global/en/products",
        ResourceChangeListener.CHANGES + "=ADDED",
        ResourceChangeListener.CHANGES + "=CHANGED"
    }
)
public class ProductPageChangeListener implements ResourceChangeListener { }

Prefer ResourceChangeListener for three reasons: it is at the Sling resource level rather than raw JCR, it is configured declaratively rather than by writing registration code, and — importantly — it works in a distributed setup where JCR observation semantics get murky.

Now the part that causes incidents: the observation queue.

Changes are delivered asynchronously. Each listener effectively has a queue of pending changes. Oak commits the write, queues the events, and delivers them to listeners on a background thread.

So what happens if a listener is slow? Its queue backs up. And when queues back up badly, Oak does not silently discard the problem — it logs warnings, and the pressure can reach back into the write path itself, slowing commits down. One badly written listener degrades authoring for everyone, and the symptom — "the author instance got slow after we deployed" — points nowhere near the actual cause.

The three rules for writing one:

Rule one — narrow the path. Registering on /content means you are woken by every change anywhere in the site. Register on the smallest branch that actually matters.

Rule two — do nothing in the listener. The listener should decide "is this relevant?" and, if so, hand off to a Sling Job (file 10) and return immediately. Any real work — a repository write, an external call, a cache flush — happens in the job, not in the listener. This single rule prevents most observation incidents.

Rule three — never write in a way that re-triggers yourself. A listener on /content/energy that writes back to /content/energy will fire itself again. Infinite loops in observation are real and they are very effective at taking an instance down.

How to diagnose it: the observation MBeans in /system/console/jmx expose per-listener queue information. A backlog that grows and does not drain is the signature. If an author instance became slow after a deployment, this is on the shortlist alongside indexes.

2.14 Versioning

Versioning is in the JCR specification, and AEM builds on it.

The mechanics. A node with the mix:versionable mixin can be checked in, which captures its state — including its subtree — into version storage, under /jcr:system/rep:versionStorage. Each version has an identifier, versions form a history, and you can restore one.

In AEM, the versionable node is jcr:content. This is the payoff of section 2.6: a page version is a snapshot of the page's content subtree, and restoring it puts the content back without moving the page, renaming it, or affecting its child pages.

When AEM creates a version. The one to know is that activating a page creates a version — which is genuinely useful, because it means the repository has a record of what was published each time. Versions are also created by explicit "Create Version" actions in the UI and by some workflow steps.

What you do with them: view and restore from the Timeline rail in the Sites console, compare two versions, and — the one people forget — restore a deleted child page from the parent's version history.

The operational side. Version storage grows, forever, unless something prunes it. That is what the version purge maintenance task is for. On a long-lived author instance with heavy publishing, version storage can become a substantial fraction of the repository. Two facts worth having: versions do not duplicate binaries (section 2.10 — they reference the same blobs), and version storage is author-side; you do not replicate version history to publish.

2.15 The flat structure problem

This is one of the most common real AEM performance problems, and it is nearly always created by a migration.

The problem: put too many child nodes directly under one parent, and operations on that parent get slow. Listing children, adding a child, and — depending on the store — even reading the parent all degrade.

Why, mechanically. The parent has to track its children. On DocumentNodeStore, a node's children are managed in a way that scales poorly once the count is very large. And it is worse for orderable node types: nt:unstructured keeps its children in a defined order, which means the ordering has to be maintained, and inserting into an ordered list of fifty thousand siblings is not cheap.

A useful rule of thumb, stated honestly: keep it in the hundreds to low thousands per level, and treat tens of thousands as a problem to fix. Adobe's guidance has shifted between versions and store types, so quote it as a rule of thumb rather than a hard number — and be ready to say why rather than to defend a specific figure.

How AEM itself avoids it, which is the best evidence for the answer: DAM uploads and many generated structures use date-based bucketing — 2024/03/11/ — rather than one flat folder. Look at how AEM stores generated content under /var and you will see the same pattern. Copying that pattern is the fix.

Our project's version of this, which is a real story: an asset migration created roughly forty thousand assets as direct children of a single DAM folder. Everything touching that folder was slow — the Assets console took many seconds to open it, and a workflow iterating over it was noticeably worse. The fix was restructuring into a hierarchy by product family and year. Nothing about the assets changed; only the shape of the tree did.

A related and equally common case: a scheduled job that writes one node per run into a fixed folder. It is fine for a month and unusable after a year. The fix is the same — bucket by date, and prune.

graph TD subgraph BAD["Flat — slow"] A["/content/dam/energy/datasheets"] --> A1["40,000 direct children"] end subgraph GOOD["Bucketed — fast"] B["/content/dam/energy/datasheets"] --> B1["transformers/"] B --> B2["hvdc/"] B --> B3["grid-automation/"] B1 --> B4["2023/"] B1 --> B5["2024/"] B5 --> B6["a few hundred assets"] end

2.16 Repository restructuring in 6.4 / 6.5 — what moved out of /etc

Why it happened. /etc had become a dumping ground. Site configuration, workflow models, tags, designs, cloud service configurations and runtime data all lived there, mixed together. That created three real problems: no clean line between what a developer deploys and what an author changes; no clean line between what is code and what is content; and packaging or migrating a site meant untangling /etc by hand.

So Adobe moved things out to where they belong. The moves worth knowing:

WhatOld location (roughly)New location
Tags/etc/tags/content/cq:tags
Workflow models/etc/workflow/models/conf/global/settings/workflow/models
Workflow instances (runtime)/etc/workflow/instances/var/workflow/instances
Cloud service configurations/etc/cloudservices/conf/.../settings/cloudconfigs
Designs / static design assets/etc/designs/apps/<project> clientlibs (file 04)
Launches/etc/launches/content/launches
Segments / ContextHub configuration/etc/segmentation/conf

Plus others in the same spirit. The principle behind every one of them is the thing to say, and it is more valuable than the table:

Code went to /apps. Site configuration went to /conf. Content went to /content. Runtime data went to /var. /etc was left holding only the genuinely legacy leftovers — in practice /etc/packages and /etc/map.

Why an interviewer asks this. It is a quick way to find out whether you have worked on a modern AEM codebase or an old one. Someone who says "templates are in /etc " is describing AEM from about 2015. Someone who says "editable templates are in /conf, and /etc is mostly just packages and mappings now" has worked on something current.

Two practical notes. Adobe shipped compatibility packages so upgraded instances kept working with old paths — which means an upgraded 6.5 instance may still have plenty in /etc. And on AEM as a Cloud Service there is no such backwards compatibility to lean on: the new locations are the only locations, which is one of the real work items when moving a 6.5 codebase to the cloud (file 14).


3. Internal Working

3.1 The Oak layer cake

graph TD A["Your code<br/>Sling Models · servlets · services"] --> B["Sling Resource API<br/>ResourceResolver · Resource · ValueMap"] B --> C["JCR API — javax.jcr<br/>Session · Node · Property"] C --> D["Oak — the JCR implementation<br/>MVCC · commit hooks · security · query engine"] D --> E["NodeStore<br/>the tree structure and small values"] D --> F["Binary store<br/>large binaries, by content hash"] E --> G["SegmentNodeStore / TarMK<br/>tar files on local disk<br/>fast · single instance<br/>AEMaaCS PUBLISH"] E --> H["DocumentNodeStore / MongoMK<br/>documents in MongoDB<br/>clusterable · network hop<br/>AEMaaCS AUTHOR"] F --> I["File data store · S3 · Azure Blob<br/>deduplicated by content hash"]

Four things to point at when you draw this:

Your code almost never touches JCR directly. It goes through Sling's Resource API, which is why ResourceResolver rather than Session is the everyday object.

Oak is where the interesting behaviour lives — MVCC, security enforcement, the query engine and index selection.

The NodeStore is pluggable, and that is the whole point. The same content model runs on tar files or on MongoDB.

Binaries branch off before the NodeStore. They never enter the tree; the tree keeps a reference.

3.2 What actually happens when you save a change

sequenceDiagram participant C as Your code participant R as ResourceResolver participant S as JCR Session participant O as Oak core participant N as NodeStore participant B as Binary store participant L as Observation listeners C->>R: getResource + ModifiableValueMap.put(...) R->>S: change held as TRANSIENT state (in memory only) Note over S: nothing is persisted yet —<br/>nobody else can see this C->>R: resolver.commit() R->>S: session.save() S->>O: apply the change set O->>O: validate: node types, constraints, permissions O->>O: check for CONFLICTS with other commits (MVCC) alt a large binary is involved O->>B: write the binary, get its content hash B-->>O: reference (existing one if already stored) end O->>N: persist the new revision O->>O: update SYNCHRONOUS indexes in this same commit O-->>S: commit succeeds O->>L: queue observation events (ASYNC) Note over O,L: async indexes are updated later,<br/>by a background lane — file 21 L-->>C: listeners fire on a background thread

The five points to draw out, and each one is an interview answer:

Changes are transient until you commit. map.put(...) changes nothing that anyone else can see. Forgetting resolver.commit() is the single most common "my code runs but nothing changes" bug in AEM.

Validation happens at commit, not at put. Node type constraints and permissions are checked when you save — which is why the nt:folder constraint violation from section 2.5 blows up on commit(), several lines away from the code that caused it.

Conflicts are detected at commit. Two sessions writing the same node concurrently: the second one fails. There is no queue.

Synchronous indexes are updated inside the commit; asynchronous ones are not. This is the single most important sentence for file 21, and it is why content you just wrote may not appear in a query result for a few seconds.

Observation is asynchronous. Your commit returns before listeners have run. Code that writes a node and immediately expects a listener to have finished is racing, and it will fail intermittently.

3.3 How a page becomes a URL — the repository's part

graph LR A["GET /content/energy/global/en/products.html"] --> B["Sling resolves the resource<br/>= the cq:Page node"] B --> C["Reads jcr:content<br/>on that page"] C --> D["Reads sling:resourceType<br/>= energy/components/page"] D --> E["Finds the script<br/>/apps/energy/components/page/page.html"] E --> F["HTL renders, reading properties<br/>from jcr:content and its children"]

The repository fact hiding inside this: sling:resourceType lives on jcr:content, not on the page node. That is why the page node itself is almost empty, and it is why file 01's request flow has to reach one level down before it can decide anything.


8. Coding and Configuration Examples

8.1 Reading and writing content correctly

package com.energy.core.services.impl;

import org.apache.sling.api.resource.*;
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.HashMap;
import java.util.Map;

@Component(service = ProductSyncService.class)
public class ProductSyncServiceImpl implements ProductSyncService {

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

    /** Matches the mapping in the service user configuration -- file 13. */
    private static final Map<String, Object> AUTH = Collections.singletonMap(
            ResourceResolverFactory.SUBSERVICE, "energy-content-writer");

    @Reference
    private ResourceResolverFactory resolverFactory;

    @Override
    public void markSynced(String productPath) {

        // try-with-resources: we OPENED this resolver, so we close it.
        // A leaked resolver leaks a JCR session, and enough of those
        // exhaust the instance. This is the single most important
        // line in the whole method.
        try (ResourceResolver resolver =
                     resolverFactory.getServiceResourceResolver(AUTH)) {

            Resource product = resolver.getResource(productPath);
            if (product == null) {
                // getResource returns NULL for a missing path or for a path
                // this user cannot READ. Both look identical here, which is
                // exactly why permission problems present as "not found".
                LOG.warn("Product not found or not readable: {}", productPath);
                return;
            }

            // The CONTENT lives on jcr:content, not on the cq:Page node.
            // Writing to the page node here would "work" -- it would just
            // put the property somewhere nothing reads it.
            Resource content = product.getChild("jcr:content");
            if (content == null) {
                LOG.warn("No jcr:content under {}", productPath);
                return;
            }

            // adaptTo can ALWAYS return null. On a node type that refuses
            // arbitrary properties -- nt:folder, for instance -- you may get
            // a resolver you can adapt but a commit that fails later.
            ModifiableValueMap values = content.adaptTo(ModifiableValueMap.class);
            if (values == null) {
                LOG.warn("Not modifiable: {}", content.getPath());
                return;
            }

            values.put("energySyncedAt", java.util.Calendar.getInstance());

            // NOTHING is persisted until commit(). Until this line the change
            // is TRANSIENT -- in this session's memory only, invisible to
            // everyone else. Forgetting commit() is the most common
            // "my code runs but nothing changes" bug in AEM.
            //
            // Node type validation, permission checks and MVCC conflict
            // detection all happen HERE, not on the put() above. That is why
            // a constraint violation blows up several lines from its cause.
            resolver.commit();

        } catch (LoginException e) {
            // Almost always a missing or misspelled service user mapping.
            LOG.error("Could not get a service resolver -- check the mapping", e);
        } catch (PersistenceException e) {
            // Constraint violation, permission denied, or an MVCC conflict.
            LOG.error("Commit failed for {}", productPath, e);
        }
    }
}

The six decisions to be able to defend:

try-with-resources, because we opened the resolver. File 13's ownership rule.

A service user, not an admin resolver. Least privilege, and there is no admin session on AEMaaCS anyway.

Null-check getResource, and understand that missing and unreadable look identical.

Write to jcr:content, not the page node. Section 2.6.

Null-check adaptTo. Always, everywhere — file 05's rule.

commit() is where everything actually happens — persistence, validation, permission checks and conflict detection.

8.2 The nt:folder trap, and both ways out

// ---------- WHAT BREAKS ----------

Resource folder = resolver.getResource("/content/dam/energy/datasheets");
ModifiableValueMap map = folder.adaptTo(ModifiableValueMap.class);
map.put("lastSyncedAt", Calendar.getInstance());
resolver.commit();
//        ^^^^^^^^ PersistenceException here, not on the put().
//
// If that folder is nt:folder, the node type does not permit arbitrary
// properties, so the commit fails with a constraint violation. The message
// names a node type rather than saying "you can't put properties here",
// which is why this costs people an hour.

// ---------- FIX A: use a permissive folder type ----------
// Right when the folder is YOURS -- a config folder, a /var structure.
// sling:Folder accepts any property and any child node.

Map<String, Object> props = new HashMap<>();
props.put("jcr:primaryType", "sling:Folder");
resolver.create(parent, "sync-state", props);

// ---------- FIX B: put the property on a child node ----------
// Right inside DAM, where you should NOT be changing the folder type
// out from under the Assets tooling.

Resource state = folder.getChild("energy:syncState");
if (state == null) {
    Map<String, Object> p = new HashMap<>();
    p.put("jcr:primaryType", "nt:unstructured");   // accepts anything
    state = resolver.create(folder, "energy:syncState", p);
}
state.adaptTo(ModifiableValueMap.class)
     .put("lastSyncedAt", Calendar.getInstance());
resolver.commit();

8.3 A well-behaved change listener

package com.energy.core.listeners;

import org.apache.sling.api.resource.observation.ResourceChange;
import org.apache.sling.api.resource.observation.ResourceChangeListener;
import org.apache.sling.event.jobs.JobManager;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;

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

@Component(
    service = ResourceChangeListener.class,
    property = {
        // RULE 1 -- NARROW THE PATH.
        // Registering on /content means this listener is woken by every
        // change anywhere in the site, including asset metadata and
        // unrelated branches, and then filters in Java. That is the
        // difference between waking thousands of times a day and tens.
        ResourceChangeListener.PATHS + "=/content/energy/global/en/products",

        // Only the change types we actually care about.
        ResourceChangeListener.CHANGES + "=CHANGED",
        ResourceChangeListener.CHANGES + "=ADDED"
    }
)
public class ProductChangeListener implements ResourceChangeListener {

    @Reference
    private JobManager jobManager;

    @Override
    public void onChange(List<ResourceChange> changes) {

        // RULE 2 -- DO NOTHING HERE.
        //
        // Decide relevance, hand off, return. Observation delivery is
        // queued per listener; a slow listener backs its queue up, and
        // that back-pressure reaches into the WRITE path -- so one bad
        // listener slows authoring down for everyone, on every page,
        // which is a symptom that points nowhere near this class.
        //
        // Absolutely no external calls, no repository writes, no loops
        // over large result sets in here.
        for (ResourceChange change : changes) {

            if (!change.getPath().endsWith("/jcr:content")) {
                continue;   // only care about page content changes
            }

            // The Sling Job does the real work: retries, offloading,
            // and it can't back up the observation queue. File 10.
            Map<String, Object> payload = Collections.singletonMap(
                    "path", (Object) change.getPath());
            jobManager.addJob("energy/product/syndicate", payload);
        }

        // RULE 3 -- never write back into the path you listen on.
        // A listener on /content/energy that writes to /content/energy
        // fires itself again. That loop is real and it takes instances down.
    }
}

8.4 Content package filters — where each thing belongs

<!-- ui.apps/src/main/content/META-INF/vault/filter.xml
     IMMUTABLE content: code. Deployed with the image on AEMaaCS. -->
<workspaceFilter version="1.0">
    <filter root="/apps/energy"/>
    <filter root="/apps/energy-vendor-packages"/>

    <!-- Oak index definitions are authored HERE, as code, and land at
         /oak:index. They belong in git, they are reviewed, and they are
         never hand-edited on a cloud environment. File 21 covers the
         naming convention that makes this safe. -->
    <filter root="/oak:index/energyProductsLucene-custom-1"/>
</workspaceFilter>
<!-- ui.content/src/main/content/META-INF/vault/filter.xml
     MUTABLE content: configuration and content the running instance owns. -->
<workspaceFilter version="1.0">
    <!-- mode="merge" so we don't wipe author changes on every deploy.
         A plain replace filter on /conf is a classic way to destroy
         policies an author configured. -->
    <filter root="/conf/energy" mode="merge"/>
    <filter root="/content/energy" mode="merge"/>
    <filter root="/content/dam/energy" mode="merge"/>
</workspaceFilter>

The mistake this prevents: putting a page under ui.apps, or a component under ui.content. On AEMaaCS the first cannot be written at runtime and the second is not in the image — and both fail in ways that are much easier to prevent than to diagnose.

8.5 Reading the repository safely from a Sling Model

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

    /** Hard ceiling. A misconfigured root degrades; it doesn't kill the pod. */
    private static final int MAX = 200;

    @SlingObject
    private Resource resource;

    private List<String> childTitles;

    @PostConstruct
    protected void init() {
        // ONCE, here -- not in the getter. HTL can call a getter many
        // times per render (file 05, story 1).
        this.childTitles = new ArrayList<>();

        for (Resource child : resource.getChildren()) {
            if (childTitles.size() >= MAX) {
                break;      // bounded, always
            }
            // The content is on jcr:content, NOT on the page node.
            // Reading getValueMap() on the page node returns almost
            // nothing and produces a mysteriously empty list.
            Resource content = child.getChild("jcr:content");
            if (content != null) {
                String title = content.getValueMap()
                        .get("jcr:title", String.class);
                if (StringUtils.isNotBlank(title)) {
                    childTitles.add(title);
                }
            }
        }
    }

    /** Empty list, never null. */
    public List<String> getChildTitles() {
        return childTitles;
    }
}

Why this shape: a bounded traversal of a known path needs no Oak index and has predictable cost. That is the bridge to file 21 — the alternative, a repository-wide query, needs an index and can traverse if it does not have one.


9. Common Mistakes

The mistakeWhat actually happensThe fix
Forgetting resolver.commit()Code runs, logs look fine, nothing is persistedCommit; remember changes are transient until then
Reading properties from the page nodeEverything is nullRead from jcr:content
Writing a property to an nt:folderConstraint violation at commit, with an unhelpful messagesling:Folder, or a nt:unstructured child node
Saying "AEMaaCS uses MongoDB"Half right — author only; publish is SegmentAuthor = Document/Mongo, Publish = Segment
Saying "Oak is Jackrabbit 3"It's a separate projectOak replaced classic Jackrabbit from AEM 6.0
Not closing a resolver you openedSession leak → memory exhaustiontry-with-resources
Closing request.getResourceResolver()Breaks the rest of the requestOnly close what you opened
Logging out a session you got from adaptToBreaks the resolver that owns itClose the resolver instead
Holding one resolver in a service field for hoursStale snapshot — new content never appearsOpen per execution, close per execution
Thousands of children under one nodeEverything touching that parent gets slowBucket by date or category
An observation listener on /contentWoken by every change in the siteRegister on the narrowest branch
Real work inside a listenerQueue backs up; write path slows for everyoneDecide and delegate to a Sling Job
A listener writing into the path it listens onInfinite loopWrite elsewhere, or guard explicitly
Assuming deleting assets frees diskNodes go, blobs stay until GCRun data store GC; mark on all instances first
Assuming maintenance is automatic on 6.5Tasks may be unconfigured or silently failingCheck the Operations Dashboard for last success
Editing /libsOverwritten on the next service pack or upgradeOverlay into /apps
Putting content in ui.appsImmutable at runtime on AEMaaCSui.content, with mode="merge"
Expecting /bin or /system in CRXDEThey're virtual, served by servletsNot a bug
Expecting a query to see content you just wroteAsync indexes lag by secondsFile 21 — know which indexes are sync
int for a JCR numberJCR has Long, not IntegerInteger / Long, and let Sling convert

10. Best Practices

On structure. Never let a node accumulate thousands of children — bucket by date or category, the way AEM does for DAM uploads. Any code that creates nodes in a loop needs a bucketing strategy before it ships.

On the page split. Always read and write page content on jcr:content. When something is unexpectedly null, check which node you are actually reading before you check anything else.

On node types. Use nt:unstructured where you need flexibility, sling:Folder where a folder must carry properties, and never fight nt:folder — put the properties somewhere they are allowed.

On sessions. Close what you opened, never close what Sling gave you, and keep resolvers short-lived so snapshots do not go stale. Use a service user with the narrowest permissions that work, never an admin resolver.

On commits. Commit once per logical unit of work, not once per property. Handle PersistenceException — it means constraint violation, permission denied, or a conflict, and treating all three as "something went wrong" loses the diagnosis.

On binaries. Do not copy them to make variants when a reference will do. Trust deduplication. And remember deletion is two-phase when someone asks why disk did not shrink.

On observation. Narrowest path, no real work in the listener, hand off to a job, never write back into the branch you listen on.

On maintenance (6.5). Monitor task success, not task existence. Alert on the growth trend, not only on the threshold. Know the mark-and-sweep rule before you touch a shared data store.

On AEMaaCS. Treat /apps and /libs as read-only in your head as well as in the platform. Everything that must survive a pod restart is either in the build or created by Repoinit.


11. Debugging Tips

The first question on any repository problem is always: what does the tree actually look like? Not what you think it looks like. Open CRXDE and read the real node — the real primary type, the real property names, the real path. A surprising share of "impossible" bugs are a property one level up from where you were reading.

The second question is: transient or committed? If code appears to do nothing, check for a missing commit() before checking anything else.

ToolPathWhat it answers
CRXDE Lite/crx/deWhat the tree really looks like — types, property names, actual values
Package Manager/crx/packmgrWhat was deployed, and what a package's filters actually cover
Operations Dashboard — Maintenance/libs/granite/operations/content/maintenance.htmlWhich maintenance tasks are enabled, and when each last succeeded
Diagnosis Tools/libs/granite/operations/content/diagnosis.htmlHealth checks and system status in one place
JMX / MBeans/system/console/jmxRepository statistics, session counts, observation queue backlog, index status
Query Performance/libs/granite/operations/content/diagnosistools/queryPerformance.htmlSlow queries and traversal warnings (file 21)
Felix console — bundles/system/console/bundlesWhether your bundle is even ACTIVE
error.loglogsConstraint violations, commit failures, listener exceptions
.json on a path/content/....1.jsonThe node and its properties as JSON — fast, and works without CRXDE
oak-runcommand lineOffline compaction, index tooling, deep repository inspection

A trick worth knowing: appending .N.json to any content path returns that node's properties N levels deep — .1.json for the node, .2.json to include children. It is the fastest way to see a node's real property names without opening CRXDE, and it works from a browser or curl.

And the security note that goes with it: .infinity.json returns an entire subtree, which is why the dispatcher must block it on publish (file 01). The fact that it is useful for debugging is exactly why it is dangerous when exposed.

Reading a constraint violation. The message names a node type. Look up what that type permits. Nine times out of ten it is nt:folder refusing a property, or a property being written onto a node type that does not allow it.

Diagnosing "the instance got slow" in order: indexes and queries first (file 21), then observation queue backlog, then session counts, then content shape. That ordering is cheapest-first and it matches how often each is actually the cause.


12. Performance Notes

Bounded traversal beats a repository-wide query. Walking the children of a known path needs no index and has predictable cost. A repository-wide query needs an index, and without one Oak traverses — which is file 21's entire subject.

Node count per parent is a performance characteristic. Hundreds to low thousands per level. Beyond that, bucket. This is not a style preference; it is the difference between a console that opens instantly and one that takes ten seconds.

Commit granularity matters both ways. Committing per property in a loop of ten thousand nodes is ten thousand commits, each with validation and index updates. Committing once at the end holds ten thousand changes in memory. Batch — commit every few hundred — and you get neither problem.

Depth costs less than width. A deep tree is generally fine. A wide one is the problem. When in doubt, go deeper.

Observation is on the write path in effect. Even though delivery is asynchronous, a backed-up queue applies pressure that slows commits. Listener design is a write-performance concern, not just a feature concern.

Sessions are not free. Each holds a snapshot and memory. Open late, close early, never hold one across requests.

Binaries are already deduplicated. Do not build your own copy-to-make-a-variant logic; you will turn a reference copy into a byte copy.

Maintenance is a performance feature, not just a disk feature. A repository carrying years of unreclaimed revisions is slower, not merely larger.


15. Comparison Tables

SegmentNodeStore versus DocumentNodeStore

Segment (TarMK)Document (MongoMK)
Stores content asSegments in .tar filesDocuments in a database (usually MongoDB)
LocationLocal diskShared database, over the network
SpeedFaster — local readsSlower per read — network hop
ClusteringNo — one instance per storeYes — instances share the store
AEM 6.5The common choice on both tiersClustered author only, less common
AEMaaCSPublish — each pod its own copyAuthor — the clustered tier
Scales byAdding independent instancesAdding instances that share content

Node store versus binary store

Node storeBinary store
HoldsTree structure, small valuesLarge binaries
Addressed byPathContent hash
Duplicate contentStored per nodeDeduplicated
Copying a subtreeCopies nodesCopies references, not bytes
Cleaned byRevision GC / compactionData store GC (mark and sweep)
Delete behaviourImmediateDeferred until GC

nt:folder versus sling:Folder versus nt:unstructured

nt:foldersling:Foldernt:unstructured
Arbitrary propertiesNoYesYes
Arbitrary childrenRestrictedYesYes
Children orderedNoNo (sling:OrderedFolder does)Yes
Typical useGeneric/DAM foldersConfig folders, /varComponents, dialogs
Failure modeConstraint violation at commit—Ordering cost with many siblings

cq:Page versus jcr:content (cq:PageContent)

cq:Pagejcr:content
RepresentsThe page's identityThe page's content
Name isThe URL segmentAlways jcr:content
HoldsChild pagesTitle, template, resource type, components
VersionableNoYes — mix:versionable
sling:resourceTypeNoYes
Reached bypage.getPath()page.getContentResource()

ResourceResolver versus Session

ResourceResolverSession
LayerSlingJCR
Resource typesYesNo
URL mappingYesNo
Non-JCR resourcesYesNo
Checked exceptionsMostly notRepositoryException everywhere
Save methodcommit()save()
Use forAlmost everythingVersioning, node types, observation, some ACL work

Immutable versus mutable on AEMaaCS

ImmutableMutable
Paths/apps, /libs/content, /conf, /var, /home, /etc, /oak:index, /tmp
Written byThe build / container imageThe running instance
Writable at runtimeNo, by anyoneYes
Maven moduleui.appsui.content
Survives redeployReplaced by the new imagePersists

AEM 6.5 versus AEMaaCS — repository operations

6.5AEMaaCS
Author node storeUsually TarMK; Mongo if clusteredDocument / MongoDB
Publish node storeTarMKSegment, per pod
Revision GCYour maintenance windowAdobe manages
Data store GCYour maintenance windowAdobe manages
CRXDE on productionTechnically possible, bad practiceNot available
/apps writableYesNo
Admin userExistsNot available to you
Content between environmentsPackages you buildAdobe's tooling

Next topic

21-Querying-QueryBuilder-and-Indexes — the other half of Oak. Why every query needs an index or Oak traverses, QueryBuilder's predicate map and the predicates you actually use, JCR-SQL2 underneath it, Oak index definitions and the AEMaaCS naming convention, how to prove a query is using an index, and the practical rules that keep a listing component from taking down a publish instance.


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