Resource Change Listener — AEM as a Cloud Service
Overview
org.apache.sling.api.resource.observation.ResourceChangeListener is the preferred API on AEM CS for reacting to repository content changes. The listener runs on a shared Sling thread and should return as quickly as possible.
For any blocking, repository-intensive, network, workflow, replication, indexing, asset-processing, or otherwise non-trivial work, enqueue a Sling Job and perform the processing in a JobConsumer.
Very small in-memory operations (metrics, counters, simple event translation, lightweight filtering, or enqueueing a job) may remain inside onChange() provided they do not perform repository access, network I/O, workflow operations, or expensive computation.
Two interface variants:
Three CS-specific constraints every listener must satisfy:
PATHSare chosen by the implementer from the business scope. Do not ask the customer for rawPATHSsyntax (/content/dam,glob:/**/jcr:content/*, etc.) unless the business scope itself is unclear. Derive the path from what the listener is supposed to react to.
Classification — choose before making any changes
Already implements ResourceChangeListener and onChange() only enqueues jobs
→ Already compliant — verify against the Review Checklist only.
Already implements ResourceChangeListener and onChange() contains business logic (resolver, JCR ops, heavy processing)
→ Apply R1–R5 (skip R0).
Implements javax.jcr.observation.EventListener or EventHandler subscribed to org/apache/sling/api/resource/Resource/*
→ Apply R0 then R1–R5.
Implements EventHandler subscribed to replication / workflow / custom topics
→ Use the event-migration skill instead — not this one.
One pattern per session. If the bundle has multiple legacy listeners, migrate one class at a time.
Before starting: Read ../references/aem-cloud-service-pattern-prerequisites.md and apply SCR→DS, service-user, and SLF4J fixes if present in the same changeset.
Discovery
Detection is performed by the analyzer (../scripts/analyze.sh), run by the runbook:
Match criteria (what the detector flags): a class that implements org.apache.sling.api.resource.observation.ResourceChangeListener or ExternalResourceChangeListener (import-aware) — the modern API, flagged for review against the lightweight + JobConsumer contract.
Emitted at the class declaration, with the class header as the snippet. Parse-level only — direct implements clause; reached-via-base-class is not resolved. Legacy javax.jcr.observation.EventListener is detected by the event-migration pattern (matching the BPA subtype taxonomy); when its logic is plain content observation, that guide routes it back here. A class implementing both ResourceChangeListener and an event interface is flagged by both patterns (rare).
Resolution contract
guided — apply (guided). The analyzer locates each ResourceChangeListener; remediation is judgment-based and applied via R0–R5 (per the Classification above) in an apply session.
Complete example — before and after
Before (legacy JCR EventListener with inline logic and admin resolver)
After — Cloud Service compatible
File 1 — ACLPolicyChangeListener.java (lightweight ResourceChangeListener)
File 2 — ACLPolicyJobConsumer.java (business logic runs here, with a service-user resolver)
Required Repoinit (goes in your ui.config Repoinit OSGi config):
Required service-user mapping (ui.config, file named e.g. org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-acl-audit.cfg.json):
R0 — Convert to ResourceChangeListener (legacy source only)
Apply only when the source is javax.jcr.observation.EventListener or EventHandler subscribed to org/apache/sling/api/resource/Resource/*. Skip if the class already implements ResourceChangeListener.
Filter options (use the constants on ResourceChangeListener):
PROPERTY_NAMES_HINTis an optimization hint, not a contract. Applications must not rely on Sling delivering exclusively those property changes. Always validate the actualResourceChangedata inside the listener or consumer (change.getChangedPropertyNames(),change.getAddedPropertyNames()).
Legacy JCR event type → ChangeType mapping:
Legacy JCR Event → ResourceChange data:
R1 — Keep onChange() lightweight; offload to Sling Job
For production AEMaaCS code, the recommended pattern is:
- Extract the minimal event data required
- Build a job payload (
Map<String, Object>) - Call
jobManager.addJob(JOB_TOPIC, props) - Return immediately
Repository access, service resolver creation, workflow operations, replication, external API calls, indexing, asset processing, and other potentially expensive work belong in a JobConsumer — never inside onChange().
Per-change try/catch is mandatory. Without it, a single bad event aborts the loop and the remaining changes in the batch are silently dropped.
R2 — Create the JobConsumer (business logic)
Create a new class that moves all the business logic from the legacy onEvent / inline handleEvent / heavy onChange into process(Job):
Rules:
- Topic on the
JobConsumercomponent must match the topic used injobManager.addJob. Share the topic as astatic final Stringreferenced by both classes. - Move all business
@Referencefields (ResourceResolverFactory, domain services) to theJobConsumer. - Extract job data via
job.getProperty("key", Type.class)— never the deprecatedJobUtil.getProperty(...). - Return
JobResult.OKon success,JobResult.FAILEDon retryable failure,JobResult.CANCELfor unrecoverable failures. getServiceResourceResolverthrowsLoginExceptionon failure — it does not normally returnnull. CatchLoginException; do not add aresolver == nullbranch unless a custom wrapper is in use.
Write-side consumers — resolver.commit() is mandatory: If process() modifies JCR content, call resolver.commit() before the try-with-resources closes the resolver. Without it, changes are silently discarded:
Author-only consumers: If the action must only run on author (replication triggers, workflow launchers), inject SlingSettingsService and guard at the top of process():
Import: import org.apache.sling.settings.SlingSettingsService;
Author-only does not mean single execution. In AEMaaCS author clusters, multiple author pods may process the same logical change. If the operation must execute exactly once across the author tier (e.g. sending a notification, creating a unique audit record), use an appropriate cluster-coordination pattern — leader election (
SlingSettingsServicetopology APIs), distributed locking, idempotent processing, or another singleton mechanism. A simple run-mode check guarantees neither uniqueness nor ordering.
R3 — Choose ResourceChangeListener vs ExternalResourceChangeListener
Default to plain ResourceChangeListener (local changes only). Switch to ExternalResourceChangeListener only when the reaction must run on every cluster node, even for changes that happened elsewhere.
ExternalResourceChangeListener should be treated as an exception, not the default. Before selecting it, verify:
- The reaction must occur for changes created on other cluster nodes
- Duplicate processing is acceptable or prevented (idempotency or coordination)
- The operation is idempotent — running it N times must equal running it once
- Per-node execution is intentional (cache invalidation, distributed indexing, local warm-up)
If any of these does not hold, use plain ResourceChangeListener plus a cluster-coordination layer instead.
ExternalResourceChangeListener extends ResourceChangeListener; both interfaces expose the same onChange method. Inside onChange(), you can still branch on origin:
Picking external by default fans out writes. With N publish pods, a single replicated write triggers the consumer N times. Use
ExternalResourceChangeListeneronly when the reaction is idempotent or must run per-node (cache invalidation, distributed indexing).
R4 — Update imports
Remove (when source was JCR EventListener):
Remove (when source was EventHandler on org/apache/sling/api/resource/Resource/*):
Remove (SCR → DS):
Add — listener class:
Add — JobConsumer class:
R5 — Repoinit and service-user mapping
The JobConsumer acquires a resolver via SUBSERVICE. On AEM as a Cloud Service, the backing system user must be created via Repoinit (the repo-init OSGi factory config) and mapped through ServiceUserMapperImpl.amended. The classic UI-based user admin is not available.
Repoinit (in ui.config):
ServiceUserMapperImpl.amended-<bundle>.cfg.json:
The bundle symbolic name on the left of : must match the bundle owning the JobConsumer — a common cause of LoginException is a typo or copy-paste from another bundle.
See ../references/aem-cloud-service-pattern-prerequisites.md for the full Repoinit workflow.
Review Checklist
Cross-cutting:
- No
javax.jcr.observation.*imports remain - No
EventHandlersubscribed toorg/apache/sling/api/resource/Resource/*topics - Business logic lives in a
JobConsumer— never inline inonChange() -
getServiceResourceResolver(SUBSERVICE)used — notgetAdministrativeResourceResolver - Resolver in try-with-resources — not a field
- OSGi DS R6 annotations — no
org.apache.felix.scr.annotations.*imports - SLF4J logging — no
System.out/System.err/printStackTrace
Listener class:
- Implements
ResourceChangeListenerorExternalResourceChangeListener(with a code comment justifying external) -
@Component(service = ResourceChangeListener.class, property = { PATHS..., CHANGES... }) -
PATHSstart with/;glob:prefix used for wildcard patterns -
CHANGESlists explicit values (ADDED,CHANGED,REMOVED) — not relying on defaults - No
ResourceResolverFactory,ResourceResolver,Session, orNodeoperations insideonChange() -
onChange()body only extracts data and callsjobManager.addJob(...) - Per-change body wrapped in try/catch so one bad change does not kill the batch
-
@Reference JobManagerpresent; business@Referencefields moved to the JobConsumer
JobConsumer class:
- Implements
JobConsumer -
@Component(service = JobConsumer.class, property = { PROPERTY_TOPICS + "=<topic>" })with the same topic used by the listener (share via astatic final String) - Resolver opened via
getServiceResourceResolver(SUBSERVICE)in try-with-resources -
resolver.commit()called if the consumer writes to JCR;PersistenceExceptioncaught - All business logic from the legacy listener lives here
- Job data read via
job.getProperty("key", Type.class)— not the deprecatedJobUtil.getProperty - Returns
JobResult.OK/FAILED/CANCEL; never swallows errors silently - Author-only consumers guard with
SlingSettingsService.getRunModes().contains("author")
Configuration:
- Service user created via Repoinit in
ui.config -
ServiceUserMapperImpl.amended-*.cfg.jsonmaps<bundle>:<subservice>→ the service user - Bundle symbolic name in the mapping matches the bundle owning the JobConsumer
-
mvn clean installsucceeds with no SCR-related or deprecated-API warnings
Troubleshooting
Common Pitfalls
Opening a ResourceResolver inside onChange() — blocks the shared Sling listener thread and delays every other registered listener. Defeats the entire purpose of the lightweight-listener pattern.
Using javax.jcr.observation.EventListener — legacy JCR observation should generally be avoided for new AEMaaCS development. Prefer ResourceChangeListener unless a documented platform limitation requires JCR-level observation semantics; in that case raise an Adobe support case before introducing a custom JCR listener.
Subscribing EventHandler to org/apache/sling/api/resource/Resource/* — those topics are an internal Sling dispatcher detail and deprecated as an application-facing API. Use ResourceChangeListener instead.
Missing leading / in PATHS — PATHS=content/dam (no leading slash) silently registers nothing; the listener is ACTIVE but never fires.
Topic constant mismatch — listener calls jobManager.addJob("com/example/foo", ...) but consumer property says =com/example/Foo (case-sensitive). Always share the topic as a static final String referenced by both classes.
Throwing from onChange() on one bad change — kills the rest of the batch. Wrap per-change work in try/catch and log; never let one bad event break processing of the others.
Picking ExternalResourceChangeListener by default — fires the consumer on every cluster node when one writes. With N publish pods, a single replicated write triggers the consumer N times. Use plain ResourceChangeListener unless the reaction must be cluster-wide.
Forgetting resolver.commit() in a write-side consumer — try-with-resources closes the resolver and silently discards pending changes. JCR writes need an explicit commit.
Observing overly broad paths — registering on /content, /, or large DAM trees without strong filtering can generate extremely high event volumes (an "event storm"). Always scope PATHS as narrowly as possible and use CHANGES and PROPERTY_NAMES_HINT to reduce delivery.
Listener writes back to the observed subtree — the JobConsumer modifies content under the same path being observed, causing the listener to trigger itself repeatedly. Ensure the processing is idempotent, exclude generated content paths from PATHS, or add a guard property the consumer reads to skip its own writes.
Modern Alternatives
AEM Eventing
If the requirement is to notify external systems (Adobe I/O Events, App Builder actions, webhooks, downstream services) about repository activity, evaluate AEM Eventing before introducing a new in-process ResourceChangeListener.
ResourceChangeListener is primarily for in-process reactions inside AEM — post-write side effects, cache invalidation, follow-up jobs. AEM Eventing is the supported path for out-of-process notification and external integration, with built-in delivery semantics, retries, and observability that an in-process listener does not provide.
Expert Guidance
Prefer designing consumers to be idempotent. AEMaaCS is a distributed system:
- Events may be batched
- Events may arrive after related content has changed again
- External events may be delivered from another cluster node
- Retries (
JobResult.FAILED) may execute the same job more than once - Author clusters may process the same logical change on multiple pods
Consumers should therefore:
- Tolerate duplicate execution (running the same job twice should not double-apply effects)
- Tolerate missing resources (read-before-write; the target may have moved or been deleted between event and processing)
- Tolerate reordered events (do not assume strict ordering of
onChange()batches) - Derive current state from the repository whenever possible, instead of relying solely on event payload ordering
Treat the event as a trigger to inspect current state, not as a complete description of what happened.


