Skip to content

Extending the AEM Connector

The AEM connector provides a powerful extension system that lets you customize how content is extracted, transformed, and indexed from Adobe Experience Manager. Extensions are Java classes that implement interfaces from the aem-commons library, published on Maven Central.


To create custom AEM extensions, add aem-commons to your project:

<!-- Source: https://mvnrepository.com/artifact/com.viglet.dumont/aem-commons -->
<dependency>
<groupId>com.viglet.dumont</groupId>
<artifactId>aem-commons</artifactId>
<version>2026.2.3</version>
<scope>compile</scope>
</dependency>

The AEM connector provides these extension interfaces and base classes:

Class / Interface Purpose Config field
DumAemExtAttributeInterface Custom logic for extracting or transforming individual field values attributes[].className or sourceAttrs[].className
DumAemExtContentInterface Extract additional content from AEM pages (e.g., .model.json) models[].className
DumAemExtModelJsonBase<T> Recommended abstract base class for .model.json extractors, handles fetch, parse, and error handling automatically. Prefer this over implementing DumAemExtContentInterface directly. models[].className
DumAemExtDeltaDateInterface Custom delta date resolution for incremental indexing sources[].deltaClass
DumAemExtUrlAttributeInterface Specialized URL handling with ID extraction (extends DumAemExtAttributeInterface) attributes[].className

The most commonly used extension. Implement this to transform or extract individual attribute values with custom logic.

import com.viglet.dumont.connector.aem.commons.ext.DumAemExtAttributeInterface;
import com.viglet.dumont.connector.aem.commons.DumAemObject;
import com.viglet.dumont.connector.aem.commons.context.DumAemConfiguration;
import com.viglet.dumont.connector.aem.commons.mappers.*;
import com.viglet.turing.client.sn.TurMultiValue;
public class MyCustomAttribute implements DumAemExtAttributeInterface {
@Override
public TurMultiValue consume(
DumAemTargetAttr dumAemTargetAttr,
DumAemSourceAttr dumAemSourceAttr,
DumAemObject aemObject,
DumAemConfiguration dumAemConfiguration
) {
// Example: extract a custom property and transform it
String rawValue = aemObject.getAttributes()
.get("myProperty").toString();
return TurMultiValue.singleItem(rawValue.toUpperCase());
}
}

Parameters:

Parameter Description
DumAemTargetAttr Target search field being populated (name, type)
DumAemSourceAttr Source AEM property definition (name, className)
DumAemObject AEM content node: path, title, template, jcrNode, jcrContentNode, lastModified, attributes
DumAemConfiguration Connection config: url, username, rootPath, authorSNSite, publishSNSite, providerName

Built-in implementations:

Class What it does
DumAemExtContentId Returns the AEM page path as the document ID
DumAemExtContentUrl Builds the full URL from the page path and URL prefix config
DumAemExtContentTags Fetches tags from the /jcr:content.tags.json endpoint
DumAemExtCreationDate Returns the jcr:created date
DumAemExtModificationDate Returns the cq:lastModified or jcr:lastModified date
DumAemExtPublicationDate Returns the last replication date
DumAemExtHtml2Text Converts HTML content to plain text
DumAemExtPageComponents Extracts text from responsive grid components
DumAemExtTypeName Returns the content type name
DumAemExtSourceApps Returns the provider name from configuration
DumAemExtSiteName Returns the site name

Implement this to fetch additional content from AEM, for example, calling the .model.json Sling Model exporter to get structured data.

import com.viglet.dumont.connector.aem.commons.ext.DumAemExtContentInterface;
import com.viglet.dumont.connector.aem.commons.bean.DumAemAttrMap;
public class MyModelJsonExtractor implements DumAemExtContentInterface {
@Override
public DumAemAttrMap consume(
DumAemObject aemObject,
DumAemConfiguration dumAemConfiguration
) {
DumAemAttrMap result = new DumAemAttrMap();
// Fetch the .model.json endpoint
String url = dumAemConfiguration.getUrl()
+ aemObject.getPath() + ".model.json";
// ... HTTP call to get JSON ...
result.append("fragmentPath", "/content/dam/fragment");
return result;
}
}

Referenced in the models[].className field of the configuration JSON.


Customize how the connector determines the “last modified” date for incremental indexing.

import com.viglet.dumont.connector.aem.commons.ext.DumAemExtDeltaDateInterface;
public class MyDeltaDate implements DumAemExtDeltaDateInterface {
@Override
public Date consume(
DumAemObject aemObject,
DumAemConfiguration dumAemConfiguration
) {
return Optional.ofNullable(aemObject.getLastModified())
.map(Calendar::getTime)
.orElse(null);
}
}

Referenced in the sources[].deltaClass field of the configuration JSON.



When your extension extracts data from .model.json, you can use DumAemExtModelJsonBase and the fluent DumAemComponentMapper API to eliminate boilerplate and write concise, declarative extractors.

An abstract class that handles the entire fetch → parse → error-handling lifecycle. Subclasses declare the model class and then choose how extraction is expressed:

Method Purpose
getModelClass() Returns the root bean class for Jackson deserialization
extractAttributes(model, query, aemObject, attrValues) Extracts data from the parsed model and populates the attribute map: the direct style, shown below
componentExtractors() Registers small single-concern extractors the base composes per page, instead of overriding extractAttributes (see Composing Extractors)
contentTypes() Declares one schema and one extractor composition per content type, routed by template (see Content Types and Recipes)

Minimal example:

import com.viglet.dumont.connector.aem.commons.ext.DumAemExtModelJsonBase;
import com.viglet.dumont.connector.aem.commons.ext.DumAemModelJsonQuery;
public class MyModelJsonExtractor extends DumAemExtModelJsonBase<MyModel> {
@Override
protected Class<MyModel> getModelClass() {
return MyModel.class;
}
@Override
protected void extractAttributes(MyModel model, DumAemModelJsonQuery query,
DumAemObject aemObject, DumAemAttrMap attrValues) {
attrValues.set("title", model.getTitle())
.set("description", model.getDescription());
}
}

This replaces all the boilerplate of building the URL, calling DumAemCommonsUtils.getResponseBody(), creating the ObjectMapper, handling IOException, and wrapping results in Optional.

One extractAttributes per source grows into a single method that knows every component on every template. Register DumAemComponentExtractors instead and the base runs them in order, each one covering a single component and testable on its own:

public class MyModelJsonExtractor extends DumAemExtModelJsonBase<MyModel> {
@Override
protected Class<MyModel> getModelClass() {
return MyModel.class;
}
@Override
protected List<DumAemComponentExtractor<MyModel>> componentExtractors() {
return List.of(new MyPageExtractor(), new MyBannerExtractor(), new MyNewsExtractor());
}
}

A DumAemComponentExtractor is a functional interface with the same signature as extractAttributes:

public class MyNewsExtractor implements DumAemComponentExtractor<MyModel> {
@Override
public void contribute(MyModel model, DumAemModelJsonQuery query,
DumAemObject aemObject, DumAemAttrMap attrValues) {
query.component("my-app/components/news", MyNews.class)
.first()
.attr("newsDate", MyNews::getDate)
.into(attrValues);
}
}

Overriding extractAttributes directly still works and is unchanged; the two styles are alternatives, not layers.

One source often indexes several kinds of page (an article, an event, a course) that share little. contentTypes() replaces “one source, one schema” with one schema and one extractor composition per type, and templateName() says which AEM template a page came from so the base can route it:

@Override
protected List<DumAemContentType<MyModel>> contentTypes() {
return List.of(
DumAemContentType.<MyModel>builder("article")
.fields(DumAemContentTypeField.text("headline"), DumAemContentTypeField.facet("topic"))
.extractors(new MyArticleExtractor())
.build(),
DumAemContentType.<MyModel>builder("event")
.fields(DumAemContentTypeField.text("venue"), DumAemContentTypeField.string("startsAt"))
.extractors(new MyEventExtractor())
.build());
}
@Override
protected String templateName(MyModel model, DumAemObject aemObject) {
return model.getTemplateName();
}

Only the resolved type’s extractors run for a given page, and the connector reports field coverage per content type rather than only per source. Pages whose template resolves to no declared type fall back to the flat componentExtractors(). templateNameAliases() collapses several template names onto one canonical type.

When the same content type is indexed by several institutions or sites, declare it once as a recipe: a versioned, parameterized content type applied by reference instead of copied:

// Shared, registered once: name@version, e.g. course-offer@2
DumAemRecipeLibrary library = new DumAemRecipeLibrary().register(courseOfferRecipe());
public class MySiteExtractor extends DumAemExtModelJsonBase<MyModel> {
private final DumAemRecipeContentTypes<MyModel> recipes =
DumAemRecipeContentTypes.<MyModel>builder(library)
.bind(DumAemRecipeRef.latest("course-offer"),
DumAemRecipeParameters.builder("my-site").template("curso")
.param("componentBasePath", "my-app/components").build())
.build();
@Override
protected List<DumAemContentType<MyModel>> contentTypes() {
return recipes.contentTypes();
}
@Override
protected Map<String, String> templateNameAliases() {
return recipes.templateNameAliases();
}
}

Each site supplies its own DumAemRecipeParameters (its template names, its component resource-type prefix), and both methods resolve the recipe from the library fresh on every call. Registering a higher version therefore re-applies the new fields and extractors to every site that bound the recipe by latest, on the next extraction and without touching any extension; a site that pinned course-offer@1 stays on it. A binding whose recipe is not registered is skipped, so one missing recipe never breaks the others.

A utility class that simplifies finding AEM components inside the model.json by their :type using JsonPath. It replaces the repetitive pattern of JsonPath.parse() + Filter.filter() + MAPPER.convertValue().

// Before (repeated for every component type):
DocumentContext jsonContext = JsonPath.parse(json);
Object jsonDetails = jsonContext.read("$..[?]", Filter.filter(
Criteria.where(":type").eq("my-app/components/news")));
List<MyNews> news = MAPPER.convertValue(jsonDetails, new TypeReference<>() {});
news.stream().filter(Objects::nonNull).findFirst().ifPresent(item -> { ... });
// After (one line):
query.findFirstByComponentType("my-app/components/news", MyNews.class)
.ifPresent(item -> { ... });

Available methods:

Method Description
findByComponentType(type, class) Returns all components matching the :type as a typed list
findFirstByComponentType(type, class) Returns the first matching component as Optional<T>
component(type, class) Returns a DumAemComponentMapper<T> for fluent attribute mapping

The most concise way to extract component data. Chain .attr() calls to declaratively map fields, use .also() for custom logic, and .via() to navigate into nested objects.

query.component("my-app/components/news", MyNews.class)
.first()
.attr("date", MyNews::getDate)
.attr("author", MyNews::getAuthor)
.into(attrValues);
query.component("my-app/components/teacher", Teacher.class)
.first()
.via(Teacher::getProfile)
.attr("name", Profile::getFullName)
.attr("bio", Profile::getBiography)
.into(attrValues);

Mix declarative and custom logic with .also()

Section titled “Mix declarative and custom logic with .also()”

Use .also() when you need conditional logic, computed values, or fallbacks alongside declarative mappings:

query.component("my-app/components/banner", Banner.class)
.first()
.also((banner, attrs) -> {
// Fallback logic: use background image, or color if not available
String image = banner.getBackgroundImage() != null
? banner.getBackgroundImage()
: banner.getBackgroundColor();
attrs.set("image", image);
})
.attr("title", Banner::getTitle)
.attr("richText", Banner::getRichText)
.into(attrValues);
query.component("my-app/components/carousel", Instructor.class)
.all()
.also((instructor, attrs) -> {
attrs.append("text", instructor.getName())
.append("text", instructor.getBio());
})
.into(attrValues);
Method Description
.first() Only process the first matching component
.all() Process all matching components (default)
.attr(name, getter) Map a field to a target attribute (override = true)
.attr(name, getter, override) Map a field with explicit override flag
.also(biConsumer) Execute custom logic for each processed component
.via(navigator) Navigate into a nested object, returns a new mapper of the nested type
.into(attrValues) Execute all accumulated mappings and actions
.findFirst() Returns Optional<T> for custom processing outside the chain
.stream() Returns a Stream<T> for custom processing outside the chain

DumAemExtModelJsonBase provides utility methods that address common patterns:

Extracts the last modified date, falling back to the creation date:

attrValues.append("date", lastModifiedDate(aemObject));

resolveTemplateName(templateName) + templateNameAliases()

Section titled “resolveTemplateName(templateName) + templateNameAliases()”

Normalizes AEM template names using a declarative alias map. Override templateNameAliases() to define your mappings:

@Override
protected Map<String, String> templateNameAliases() {
return Map.of(
"contact-page", "institutional",
"sub-home", "institutional",
"news-article", "news",
"knowledge-article", "news",
"webinar", "event"
);
}

Then use resolveTemplateName() in your extractor:

attrValues.set("templateName", resolveTemplateName(model.getTemplateName()));
// "contact-page" → "institutional", "news-article" → "news", etc.

Here is a complete extractor using all the abstractions:

public class MyPortalModelJson extends DumAemExtModelJsonBase<MyPortalModel> {
@Override
protected Class<MyPortalModel> getModelClass() {
return MyPortalModel.class;
}
@Override
protected Map<String, String> templateNameAliases() {
return Map.of(
"contact-page", "institutional",
"news-article", "news"
);
}
@Override
protected void extractAttributes(MyPortalModel model, DumAemModelJsonQuery query,
DumAemObject aemObject, DumAemAttrMap attrValues) {
// Root metadata
attrValues.append("date", lastModifiedDate(aemObject))
.set("fragmentPath", model.getFragmentPath())
.set("templateName", resolveTemplateName(model.getTemplateName()));
// News component
query.component("my-portal/components/news", MyNews.class)
.first()
.attr("date", MyNews::getDate)
.into(attrValues);
// Banner with image fallback
query.component("my-portal/components/banner", MyBanner.class)
.first()
.also((banner, attrs) -> {
String image = banner.getImage() != null
? banner.getImage() : banner.getFallbackImage();
attrs.set("image", image);
})
.attr("richText", MyBanner::getRichText)
.attr("modificationDate", MyBanner::getAuthorDate)
.into(attrValues);
// Event with nested address logic
query.component("my-portal/components/event", MyEvent.class)
.first()
.attr("date", MyEvent::getDate)
.attr("endDate", MyEvent::getEndDate)
.also((event, attrs) ->
attrs.append("text",
"%s %s".formatted(event.getCity(), event.getAddress())))
.into(attrValues);
// Teacher: navigate into elements
query.component("my-portal/components/teacher", MyTeacher.class)
.first()
.via(MyTeacher::getElements)
.attr("title", Elements::getName)
.attr("abstract", Elements::getQualification)
.attr("image", Elements::getPhoto)
.into(attrValues);
}
}

DumAemAttrMap is the core data structure for collecting extracted attributes. It extends HashMap<String, TurMultiValue> and provides typed methods for adding values safely (null values are silently ignored).

The fluent methods provide concise, chainable calls with clear semantics. They accept any supported type (String, Date, Boolean, Integer, Long, Double, Float, TurMultiValue) and dispatch automatically.

Sets a value, replacing any existing value for this attribute:

attrValues
.set("title", model.getTitle())
.set("date", model.getDate())
.set("active", true);

Appends a value, merging with any existing value. If the attribute does not yet exist, it is created:

attrValues
.append("text", teacher.getBio())
.append("text", teacher.getName());

Sets a value only if the attribute does not already exist in the map. Replaces the common if (!attrValues.containsKey(...)) pattern:

attrValues.setIfAbsent("abstract", description);
setAll(name, list) / appendAll(name, list): string collections
Section titled “setAll(name, list) / appendAll(name, list): string collections”
attrValues
.setAll("tags", List.of("news", "tech", "java"))
.appendAll("categories", additionalCategories);
setAllDates(name, list) / appendAllDates(name, list): date collections
Section titled “setAllDates(name, list) / appendAllDates(name, list): date collections”
attrValues.setAllDates("eventDates", List.of(startDate, endDate));

Creates a new map with a single attribute:

return DumAemAttrMap.of("title", model.getTitle());
Method Behavior Returns
set(name, value) Replace existing value this
append(name, value) Merge with existing value this
setIfAbsent(name, value) Set only if key is absent this
setAll(name, List<String>) Replace with string collection this
appendAll(name, List<String>) Merge string collection this
setAllDates(name, List<Date>) Replace with date collection this
appendAllDates(name, List<Date>) Merge date collection this
of(name, value) (static) Create map with one attribute (override) new map
ofAppend(name, value) (static) Create map with one attribute (merge) new map
ofAppendAll(name, List<String>) (static) Create map with string collection (merge) new map

All methods accept any supported type: String, Date, Boolean, Integer, Long, Double, Float, TurMultiValue.

attrValues.merge(otherAttrValues);

Combines two attribute maps. For each key in the source map:

  • If override is true on the source value → replaces the existing value
  • If override is false → appends to the existing multi-value

The AEM connector is configured via a JSON file that defines sources, attributes, locale mappings, and content models. This file is placed in an export/ directory and imported at startup.

{
"sources": [
{
"name": "WKND",
"defaultLocale": "en_US",
"localeClass": "com.viglet.dumont.connector.aem.commons.ext.DumAemExtLocale",
"deltaClass": "com.example.MyDeltaDate",
"endpoint": "http://localhost:4502",
"username": "admin",
"password": "admin",
"oncePattern": "^/content/wknd/us/en/faqs",
"rootPath": "/content/wknd",
"contentType": "cq:Page",
"author": true,
"publish": true,
"authorSNSite": "wknd-author",
"publishSNSite": "wknd-publish",
"authorURLPrefix": "http://localhost:4502",
"publishURLPrefix": "https://wknd.site",
"localePaths": [
{ "locale": "en_US", "path": "/content/wknd/us/en" },
{ "locale": "es", "path": "/content/wknd/es/es" }
],
"attributes": [
{
"name": "id", "type": "STRING", "mandatory": true,
"className": "com.viglet.dumont.connector.aem.commons.ext.DumAemExtContentId"
},
{
"name": "title", "type": "TEXT", "mandatory": true,
"facetName": { "default": "Titles", "pt_BR": "Títulos" }
},
{
"name": "tags", "type": "STRING", "multiValued": true,
"facet": true, "facetName": { "default": "Tags" },
"className": "com.viglet.dumont.connector.aem.commons.ext.DumAemExtContentTags"
},
{
"name": "url", "type": "STRING", "mandatory": true,
"className": "com.viglet.dumont.connector.aem.commons.ext.DumAemExtContentUrl"
}
],
"models": [
{
"type": "cq:Page",
"className": "com.example.MyModelJsonExtractor",
"targetAttrs": [
{ "name": "title", "sourceAttrs": [{ "name": "jcr:title" }] },
{ "name": "tags", "sourceAttrs": [{ "name": "cq:tags" }] },
{
"name": "text",
"sourceAttrs": [{
"className": "com.viglet.dumont.connector.aem.commons.ext.DumAemExtPageComponents"
}]
}
]
}
]
}
]
}
Field Type Description
name string Source identifier (displayed in the admin console)
endpoint string AEM instance URL (e.g., http://localhost:4502)
username / password string AEM authentication credentials
rootPath string Content tree root to crawl (e.g., /content/wknd)
contentType string JCR node type to index (e.g., cq:Page)
defaultLocale string Fallback locale code (e.g., en_US)
localeClass string Class for locale resolution
deltaClass string Class implementing DumAemExtDeltaDateInterface
oncePattern string Regex: matching paths are indexed only once (never re-indexed)
author / publish boolean Enable indexing from author/publish environments
authorSNSite / publishSNSite string Viglet Turing SN Site names for each environment
authorURLPrefix / publishURLPrefix string Public URL prefixes for documents
"localePaths": [
{ "locale": "en_US", "path": "/content/wknd/us/en" },
{ "locale": "es", "path": "/content/wknd/es/es" }
]

Content found under each path is tagged with the corresponding locale.

Field Type Description
name string Field name in the search index
type string STRING, TEXT, or DATE
mandatory boolean Whether this field is required
multiValued boolean Whether this field holds multiple values
description string Human-readable description
facet boolean Expose as a facet filter in search results
facetName object Localized labels: { "default": "Tags", "pt_BR": "Etiquetas" }
className string Class implementing DumAemExtAttributeInterface, extracts the value instead of reading from JCR
Field Type Description
type string JCR node type this model applies to
className string Class implementing DumAemExtContentInterface
targetAttrs[].name string Target field (must match attributes[])
targetAttrs[].sourceAttrs[].name string JCR property to read (e.g., jcr:title)
targetAttrs[].sourceAttrs[].className string Class implementing DumAemExtAttributeInterface for custom extraction

<project>
<groupId>com.example</groupId>
<artifactId>my-aem-extensions</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>com.viglet.dumont</groupId>
<artifactId>aem-commons</artifactId>
<version>2026.2.3</version>
</dependency>
</dependencies>
</project>

Attribute extension (for individual fields):

package com.example.ext;
import com.viglet.dumont.connector.aem.commons.ext.DumAemExtAttributeInterface;
import com.viglet.turing.client.sn.TurMultiValue;
public class MyBreadcrumb implements DumAemExtAttributeInterface {
@Override
public TurMultiValue consume(DumAemTargetAttr target,
DumAemSourceAttr source, DumAemObject aemObject,
DumAemConfiguration config) {
String path = aemObject.getPath()
.replace(config.getRootPath(), "");
return TurMultiValue.singleItem(
String.join(" > ", path.split("/")));
}
}

Model JSON extension (for structured .model.json data):

package com.example.ext;
import com.viglet.dumont.connector.aem.commons.ext.DumAemExtModelJsonBase;
import com.viglet.dumont.connector.aem.commons.ext.DumAemModelJsonQuery;
public class MyModelJson extends DumAemExtModelJsonBase<MyModel> {
@Override
protected Class<MyModel> getModelClass() {
return MyModel.class;
}
@Override
protected void extractAttributes(MyModel model, DumAemModelJsonQuery query,
DumAemObject aemObject, DumAemAttrMap attrValues) {
attrValues.set("title", model.getTitle());
query.component("my-app/components/news", MyNews.class)
.first()
.attr("date", MyNews::getDate)
.into(attrValues);
}
}
Terminal window
mvn clean package
cp target/my-aem-extensions-1.0.0.jar /appl/viglet/dumont/aem/libs/

The libs/ directory must contain both aem-plugin.jar and your extension JAR.

{
"name": "breadcrumb",
"type": "STRING",
"className": "com.example.ext.MyBreadcrumb"
}

Extension classes are loaded via DumCustomClassCache using Class.forName(). Requirements:

  • Public no-argument constructor
  • On the classpath (via libs/ and -Dloader.path)
  • Thread-safe (one instance is shared across all calls)

Page Description
AEM Connector AEM connector features, configuration, and locale mapping
Installation Guide How to deploy plugins with -Dloader.path
Developer Guide Project structure, build, and contribution guide