AEM Backend Development: The Complete Guide
A complete map of AEM backend development — components, templates, editable templates, policies, client libraries, dialogs, multifields, Sling Models, HTL, and Context-Aware Configuration — plus the advanced building blocks: custom workflow steps, event handlers, resource listeners, scheduled jobs, and the Replication API. With code, a cheat sheet, best practices, and do's & don'ts.
"AEM backend development" is a broad phrase. On any given day it can mean building a component, wiring an editable template, writing a Sling Model, reacting to a content change, running a nightly job, or kicking off a publish from code. This guide is a single map of that whole surface — the everyday building blocks first, then the advanced server-side pieces that turn a content site into a real application.
The foundational topics here each have a dedicated deep-dive elsewhere on this blog, so I'll explain what each is and how the pieces fit together and link you onward for the full treatment — that keeps this guide cohesive without repeating thousands of words. The advanced half — custom workflow steps, event handlers, listeners, scheduled jobs, and replication APIs — gets full, original depth, because that's where most "how do I actually do this?" questions live. As always, there's a cheat sheet, best practices, and do's & don'ts at the end.
If you're new to the platform, start with the AEM Developer Cheat Sheet; the building blocks below assume that vocabulary.
The building blocks
These are the pieces you assemble into a typical authored experience. They're introduced here in the order content actually flows — from the template that defines a page, to the component an author drops onto it, to the Java and markup that render it.
Components
A component is the unit authors place on a page — a node of type cq:Component whose sling:resourceType links content to the script that renders it. The modern convention is to create a thin proxy that inherits an Adobe Core Component via sling:resourceSuperType, then customize only what you need, so you inherit accessibility, the data layer, and JSON export for free.
<jcr:root jcr:primaryType="cq:Component"
jcr:title="Teaser" componentGroup="My Site - Content"
sling:resourceSuperType="core/wcm/components/teaser/v2/teaser"/>
The full anatomy — edit config, design dialog, the render flow — is covered in the Component Development guide.
Templates, editable templates & policies
A template defines the structure a new page starts with. Modern projects use editable templates (stored in /conf, authored in the Template Editor) rather than legacy static templates, because they stay dynamically linked to their pages — update the template's structure and existing pages reflect it. A template's per-component configuration lives in policies (also in /conf), which are reusable settings applied in the context of a template. The same component can behave differently on different templates purely through policies, with no code change.
The distinction and the /conf layout are explained in detail in the AEM Developer Cheat Sheet.
Client libraries
CSS and JavaScript are delivered through client libraries — cq:ClientLibraryFolder nodes with categories, dependencies, and embed metadata. AEM bundles, minifies, and versions them, and you serve them via the /etc.clientlibs proxy so the /apps tree is never exposed. Include one from HTL with the built-in clientlib template. (See the Developer Cheat Sheet for the full breakdown of categories, dependencies, and embeds.)
Dialogs & multifields
A dialog (_cq_dialog) is the authoring form, built from Granite UI fields organized into tabs; each field's name attribute decides which JCR property the value is saved to. A multifield is the special field for repeating groups — a composite multifield stores each entry as a child node, which you later read as a List<Resource> in your model. Both are covered, with full XML, in the Component Development guide.
Sling Models & HTL
The logic and markup layers. A Sling Model adapts a Resource or request into a typed Java object — all your component logic belongs here, exposed through getters. HTL then binds that model and renders escaped, XSS-safe markup, with no logic of its own.
@Model(adaptables = Resource.class,
defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class TeaserModel {
@ValueMapValue private String heading;
public String getHeading() { return heading; }
}
<sly data-sly-use.teaser="com.mysite.core.models.TeaserModel"/>
<h2>${teaser.heading}</h2>
Go deep on Models in the Annotations reference and the Sling guide, and on templating in the HTL cheat sheet.
Context-Aware Configuration
When configuration needs to vary by content path — a different API key per brand, a feature flag per region — a single global OSGi config can't express it. Context-Aware Configuration (CA Config) resolves values based on a content tree's sling:configRef, so the same code returns the right value for each site. (Editable-template policies are themselves built on it.) Full example in the Sling guide.
Advanced backend
This is where AEM becomes an application platform rather than a content renderer. Each of the following lets your code participate in AEM's runtime — reacting to content, automating processes, and moving content between tiers.
Custom workflow steps
Workflows automate content processes — review-and-publish, asset processing, translation. A workflow is a series of steps, and the most common custom step is a process step: a piece of Java that runs when the workflow reaches it. You implement the Granite WorkflowProcess interface and register it as an OSGi service with a process.label, which is how authors select it in the workflow model.
@Component(
service = WorkflowProcess.class,
property = { "process.label=My Site - Publish Approver" })
public class PublishApproverStep implements WorkflowProcess {
@Override
public void execute(WorkItem workItem, WorkflowSession wfSession, MetaDataMap args)
throws WorkflowException {
// The content the workflow is acting on (e.g. a page path)
String payloadPath = workItem.getWorkflowData().getPayload().toString();
// Step arguments configured on the model (PROCESS_ARGS)
String config = args.get("PROCESS_ARGS", String.class);
// ... do the work: validate, transform, replicate, notify ...
}
}
The key objects: the WorkItem carries the payload (the path the workflow is operating on), the WorkflowSession lets you advance, route, or complete the workflow, and the MetaDataMap delivers the step's configured arguments. Once deployed, the step shows up in the workflow model's Process Step component, selected by its process.label.
Tip: Keep workflow steps fast and resilient. They run inside the workflow engine, so a step that blocks on a slow external call holds up the whole workflow. For heavy or unreliable work, have the step enqueue a Sling Job (below) and complete quickly.
Event handlers
An event handler lets your code react to things happening inside AEM — a resource added, a page modified, content replicated. AEM publishes these as OSGi events, and you subscribe by implementing org.osgi.service.event.EventHandler and declaring which topics you care about.
@Component(
service = EventHandler.class,
property = {
EventConstants.EVENT_TOPIC + "=" + SlingConstants.TOPIC_RESOURCE_ADDED
})
public class ResourceAddedHandler implements EventHandler {
@Override
public void handleEvent(Event event) {
String path = (String) event.getProperty(SlingConstants.PROPERTY_PATH);
// react to the newly added resource
}
}
Common topics include the Sling resource events (TOPIC_RESOURCE_ADDED, _CHANGED, _REMOVED), replication events, and page events. Two caveats matter: event handlers run on the instance where the event fires (not cluster-wide), and they're invoked by the OSGi EventAdmin, so a slow handler can back up event delivery — keep them lightweight, and offload real work to a job.
Listeners
When you specifically need to observe content changes under a path, the modern, recommended tool is Sling's ResourceChangeListener — it's path-scoped, type-filtered, and cleaner than the low-level JCR ObservationManager/EventListener API it replaces.
@Component(
service = ResourceChangeListener.class,
property = {
ResourceChangeListener.PATHS + "=/content/mysite",
ResourceChangeListener.CHANGES + "=ADDED",
ResourceChangeListener.CHANGES + "=CHANGED",
ResourceChangeListener.CHANGES + "=REMOVED"
})
public class ContentChangeListener implements ResourceChangeListener {
@Override
public void onChange(List<ResourceChange> changes) {
for (ResourceChange change : changes) {
// change.getType(), change.getPath()
}
}
}
Prefer ResourceChangeListener over a raw JCR listener for almost everything — you declare the paths and change types as service properties, and Sling handles the wiring. As with event handlers, observation happens per-instance, so if a content change must trigger exactly one downstream action across a cluster, observe the change and then enqueue a Sling Job rather than acting directly in the callback.
Note: Event handlers and resource listeners overlap. Rule of thumb: use a
ResourceChangeListenerwhen you're watching content changes under known paths; use anEventHandlerfor non-resource events (replication, workflow, custom OSGi events) identified by topic.
Scheduled jobs
Server-side work that runs on a timetable uses the Sling Scheduler; work that must run reliably and exactly once across a cluster uses Sling Jobs. The distinction is critical on AEM as a Cloud Service, where instances scale horizontally.
A scheduled task is a Runnable configured with a cron expression:
@Component(service = Runnable.class, immediate = true)
@Designate(ocd = NightlyTask.Config.class)
public class NightlyTask implements Runnable {
@ObjectClassDefinition(name = "Nightly Task")
public @interface Config {
@AttributeDefinition(name = "Cron") String scheduler_expression() default "0 0 2 * * ?";
}
@Override public void run() { /* periodic work */ }
}
The catch is that a scheduled task fires on every instance it's deployed to. When you need a job to run only once cluster-wide — sending an email, calling an API — hand it to the JobManager and process it in a JobConsumer, which Sling dispatches to a single instance and retries on failure. Both patterns, and exactly when to choose each, are detailed in the Apache Sling guide.
Replication APIs
Publishing content from code — after a workflow approves it, or in a custom import — uses the Replicator service. You call it with a session, an action, and a path:
@Reference
private Replicator replicator;
public void publish(ResourceResolver resolver, String path) throws ReplicationException {
Session session = resolver.adaptTo(Session.class);
replicator.replicate(session, ReplicationActionType.ACTIVATE, path);
}
The ReplicationActionType selects the operation — ACTIVATE (publish), DEACTIVATE (unpublish), or DELETE — and a ReplicationOptions argument can make the call synchronous or apply a filter. On classic AEM this drives the replication agents; on AEM as a Cloud Service the same API runs over Sling Content Distribution under the hood, so your code stays the same. A common pattern is to call the Replicator from a custom workflow step so that approval and publishing are one automated flow.
Important: Replicate with a properly-permissioned service user, never an admin session, and remember that activation also drives Dispatcher cache invalidation — see the Dispatcher guide for how the flush propagates.
Cheat sheet
| Need | Use | Registered as |
|---|---|---|
| Render authored content | Component + Sling Model + HTL | cq:Component, @Model |
| Page structure & config | Editable template + policies | /conf |
| Ship CSS/JS | Client library | cq:ClientLibraryFolder |
| Author input | Dialog + (multifield) | _cq_dialog |
| Per-path config | Context-Aware Configuration | @Configuration |
| Custom workflow logic | WorkflowProcess | service = WorkflowProcess.class + process.label |
| React to OSGi events | EventHandler | event.topics property |
| Watch content changes | ResourceChangeListener | resource.paths + resource.change.types |
| Periodic task | Runnable + scheduler.expression | runs per instance |
| Once-only task | JobManager + JobConsumer | runs once across cluster |
| Publish from code | Replicator | replicate(session, ACTIVATE, path) |
Best practices
- ✅ Build components as proxies of Core Components; keep logic in Sling Models, markup in HTL.
- ✅ Configure with editable templates, policies, and CA Config rather than code branches.
- ✅ Keep workflow steps, event handlers, and listeners fast — offload real work to Sling Jobs.
- ✅ Prefer
ResourceChangeListenerover raw JCR observation. - ✅ Use the Scheduler for periodic work, Jobs for once-only work.
- ✅ Replicate via the
Replicatorwith a service user, and account for cache invalidation.
Do's and Don'ts
Do
- ✅ Enqueue a Sling Job from a workflow step / listener when work is heavy or must run once.
- ✅ Scope listeners and event handlers tightly (paths, topics, change types).
- ✅ Call the Replicator from a workflow step to automate approve-and-publish.
Don't
- ❌ Don't block inside a workflow step, event handler, or listener with slow external calls.
- ❌ Don't use the Scheduler for cluster-wide once-only work — it runs on every instance.
- ❌ Don't use raw JCR
EventListenerwhenResourceChangeListenerwill do. - ❌ Don't replicate with an admin session — use a service user.
- ❌ Don't put business logic in HTL or duplicate config in code that belongs in policies / CA Config.
Wrapping up
AEM backend development is a layered craft: the building blocks (components, templates, policies, client libraries, dialogs, Sling Models, HTL, CA Config) compose the authored experience, and the advanced pieces (workflow steps, event handlers, listeners, scheduled jobs, and replication) let your code drive AEM's runtime. The thread connecting the advanced half is discipline about where work runs: keep callbacks fast, push heavy or once-only work to Sling Jobs, and always act with a scoped service user.
From here, go deep where you need it — the Component Development guide, the Apache Sling guide, the OSGi guide, and the Annotations reference — and scaffold the boilerplate with my AEM Component Generator.
Subscribe to the Newsletter
Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.