The Apache Sling Framework: A Complete Guide for AEM Developers
A deep, practical guide to Apache Sling — the framework at the heart of AEM. Covers resource resolution, Resource vs Node, Sling Models, Servlets, Filters, the Scheduler, Sling Jobs, the ResourceResolver API, adaptables, and Context-Aware Configuration, with code, a cheat sheet, best practices, and do's & don'ts.
If the JCR is where AEM stores content and OSGi is where your code runs, then Apache Sling is the layer that connects the two — and it's the single most important framework for an AEM developer to truly understand. Almost everything you do on the platform is Sling underneath: a URL becoming a rendered page, a Java class reading content, a servlet answering an AJAX call, a background job processing an order. Learn Sling well and AEM stops feeling like a black box.
This guide walks through the parts of Sling you'll use on real projects, one at a time, with the why behind each and runnable code throughout. By the end you'll understand how a request turns into a resource and a script, the crucial difference between a Resource and a Node, and the full toolbox — Models, Servlets, Filters, the Scheduler, Jobs, the ResourceResolver API, adaptables, and Context-Aware Configuration. A cheat sheet, best practices, and do's & don'ts close it out.
For the surrounding platform, see the AEM Developer Cheat Sheet; for the annotations referenced here, the Annotations reference.
What Sling is, in one paragraph
Apache Sling is a RESTful web framework built on top of a content repository. Its founding idea is that everything is a resource addressable by a URL, and that the resource itself — specifically its type — decides how it's processed. Rather than mapping URLs to controllers the way a traditional MVC framework does, Sling maps a URL to a piece of content, reads that content's type, and then finds a script or servlet registered for that type. This "content-driven" model is what makes AEM so flexible: authors create content, and the right code runs automatically because the content says what it is.
Sling Resource Resolution
Resource resolution is the process Sling runs on every request, and understanding it is the foundation for everything else. When a request comes in, Sling doesn't look for a route — it decomposes the URL, finds the resource it points to, and selects a script based on that resource's type.
A Sling URL breaks into well-defined parts:
/content/site/en/page.mobile.html/a/b?x=1
└──────── path ──────┘ └sel─┘ ext suffix param
resource = /content/site/en/page (has a sling:resourceType)
selectors = mobile
extension = html
suffix = /a/b
From those parts, resolution proceeds in a predictable order:
- Resolve the resource. Sling locates the content at the path — here,
/content/site/en/page. - Read its
sling:resourceType. This property (for examplemysite/components/page) is the link between content and code. - Find a script or servlet. Sling searches under the resource type for something whose name matches the request's selectors, extension, and HTTP method —
page.html, or a more specificpage.mobile.htmlif it exists. - Resolve the script's location across the search paths — by default
/appsfirst, then/libs. This is why your overlay in/appswins over Adobe's code in/libs.
Two related mechanisms are worth knowing. sling:resourceSuperType lets a resource type inherit scripts and behavior from another (the basis of the Core Component proxy pattern). And resource resolver mapping (/etc/map and Sling mapping configuration) rewrites between public URLs and internal repository paths — shortening /content/site/en/page.html to /page, for instance.
Tip: When a page renders the wrong script (or none), open the Recent Requests console (
/system/console/requests). It shows the exact resource, selectors, and the script Sling chose — the fastest way to debug resolution.
Resource vs Node
This is the distinction that separates developers who use AEM from those who understand it. Newcomers reach for the JCR Node API out of habit; experienced Sling developers work with Resource. Knowing why is important.
A Node is a pure JCR concept — a node in the repository tree, accessed through the JCR API (javax.jcr.Node). A Resource is Sling's higher-level abstraction over "a thing at a path." A Resource is usually backed by a JCR node, but it doesn't have to be: Sling can serve resources from any ResourceProvider — a file system, a database, a remote API, or a bundle. Coding against Resource means your code works regardless of where the content actually lives.
In practice you read a resource's data through its ValueMap, a type-safe map of properties, and you drop down to the JCR Node only when you genuinely need a JCR-specific feature:
Resource resource = resourceResolver.getResource("/content/site/en/page");
// Sling way — type-safe, provider-agnostic
ValueMap props = resource.getValueMap();
String title = props.get("jcr:title", String.class);
String desc = props.get("jcr:description", "No description"); // with default
// Drop to JCR only when you must
Node node = resource.adaptTo(Node.class);
Resource (Sling) | Node (JCR) | |
|---|---|---|
| Abstraction | High-level, provider-agnostic | Low-level, JCR-only |
| Read properties | getValueMap() — type-safe, null-safe with defaults | getProperty().getString() — verbose, throws |
| Backing store | JCR, file system, DB, remote (any provider) | Always the JCR |
| Recommendation | Default choice | Only for JCR-specific operations |
Rule of thumb: prefer
ResourceandValueMap. Reach forNodeand the JCR API only when a feature (versioning, locking, specific node-type operations) requires it.
Sling Models
A Sling Model is a plain Java object that Sling populates from a Resource or a SlingHttpServletRequest. It's the clean, declarative way to get content into typed Java without manual getValueMap() calls scattered through your code, and it's what your HTL templates bind to.
@Model(adaptables = Resource.class,
defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class ArticleModel {
@ValueMapValue
private String title;
@ChildResource
private List<Resource> authors;
@PostConstruct
protected void init() { /* derived values */ }
public String getTitle() { return title; }
}
The annotations declare where each field comes from — a property, a child node, a service — and Sling does the wiring. Because Models are central to component development and have their own rich set of injector annotations, they get full treatment elsewhere: see the Annotations reference for every injector, and the Component Development guide for a Model working alongside its dialog and HTL.
Sling Servlets
When the browser needs to talk to the server directly — an AJAX request, a JSON feed, a form handler — you write a Sling Servlet. Sling offers two ways to register one, and the choice has real security implications.
The preferred approach binds the servlet to a resource type, so it inherits AEM's resource-level access control and participates in normal resolution:
@Component(service = Servlet.class)
@SlingServletResourceTypes(
resourceTypes = "mysite/components/article",
selectors = "data",
extensions = "json",
methods = HttpConstants.METHOD_GET)
public class ArticleDataServlet extends SlingSafeMethodsServlet {
@Override
protected void doGet(SlingHttpServletRequest req, SlingHttpServletResponse resp)
throws IOException {
resp.setContentType("application/json");
resp.getWriter().write("{\"ok\":true}");
}
}
The other approach binds to a fixed path with @SlingServletPaths (for example /bin/mysite/export). Use it sparingly: a path-bound servlet bypasses resource-level access control and must be explicitly allowed through the Dispatcher.
A small but important detail is which base class you extend. SlingSafeMethodsServlet supports only read methods (GET, HEAD) and is the right choice for anything that doesn't modify content. SlingAllMethodsServlet adds POST, PUT, and DELETE for servlets that write. Choosing the safe one by default makes your intent explicit and your endpoint harder to misuse.
Important: Prefer resource-type-bound servlets over path-bound ones. Path-bound servlets are a recurring source of security gaps because they sidestep AEM's access control and need their own Dispatcher allow-listing.
Sling Filters
A Sling Filter intercepts requests as they flow through the engine — before and after the servlet runs — which makes it the place for cross-cutting concerns like logging, adding response headers, security checks, or request enrichment. A filter is a standard javax.servlet.Filter registered with the @SlingServletFilter annotation.
@Component(service = Filter.class)
@SlingServletFilter(
scope = { SlingServletFilterScope.REQUEST },
pattern = "/content/mysite/.*",
methods = { "GET" })
public class TimingFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
long start = System.currentTimeMillis();
chain.doFilter(req, resp); // continue down the chain
// ... record duration after the request completes ...
}
}
Two properties control where and when a filter runs:
scopedecides which part of the request lifecycle the filter participates in. The values areREQUEST(once per top-level request),INCLUDEandFORWARD(for included/forwarded resources),ERROR(error handling), andCOMPONENT(around every component render).service.rankingcontrols order within a scope — a higher ranking runs earlier. Set it explicitly when order matters.
Note: A
COMPONENT-scoped filter runs around every component on a page, so it can be called hundreds of times per request. Keep its logic extremely cheap, and preferREQUESTscope unless you genuinely need per-component interception.
Sling Scheduler
The Sling Scheduler runs code on a recurring schedule, backed by Quartz. The most common pattern is a component that implements Runnable and is configured with a cron expression, where the schedule itself is exposed as OSGi configuration so it can change per environment:
@Component(service = Runnable.class, immediate = true)
@Designate(ocd = CleanupTask.Config.class)
public class CleanupTask implements Runnable {
@ObjectClassDefinition(name = "Cleanup Task")
public @interface Config {
@AttributeDefinition(name = "Cron expression")
String scheduler_expression() default "0 0 2 * * ?"; // 2 AM daily
}
@Override
public void run() {
// scheduled work
}
}
The scheduler is the right tool for time-based work — nightly cleanups, periodic syncs, cache warming. But it has an important limitation in clustered and cloud environments: a scheduled task runs on every instance it's deployed to. On a publish farm or an auto-scaling AEMaaCS cluster, that means the same job can fire on several instances at once. When work must happen exactly once across the cluster, don't reach for the scheduler — reach for Sling Jobs.
Important: On AEM as a Cloud Service, instances come and go and scale horizontally. Use the scheduler only for idempotent, per-instance work; use Sling Jobs for anything that must run once and reliably.
Sling Jobs
Sling Jobs provide guaranteed, distributed, once-only processing. A job is a unit of work, identified by a topic, that you hand to the JobManager; Sling persists it, dispatches it to exactly one consumer across the cluster, and retries it if it fails. This is the correct foundation for asynchronous business processing — sending an email, calling an external API, processing an upload.
You create a job by adding it to the manager with a topic and a payload:
@Reference
private JobManager jobManager;
public void enqueue(String orderId) {
Map<String, Object> props = Map.of("orderId", orderId);
jobManager.addJob("mysite/order/process", props);
}
And you handle it with a JobConsumer registered for that topic:
@Component(service = JobConsumer.class,
property = JobConsumer.PROPERTY_TOPICS + "=mysite/order/process")
public class OrderJobConsumer implements JobConsumer {
@Override
public JobResult process(Job job) {
String orderId = job.getProperty("orderId", String.class);
// do the work
return JobResult.OK; // or RETRY to try again, FAILED to give up
}
}
The contrast with the scheduler is the thing to remember:
| Sling Scheduler | Sling Jobs | |
|---|---|---|
| Trigger | Time-based (cron/period) | On demand (you enqueue) |
| Execution | Runs on every instance | Runs once across the cluster |
| Persistence | None | Persisted, survives restarts |
| Retries | None | Built-in (JobResult.RETRY) |
| Use for | Periodic, idempotent tasks | Guaranteed, once-only work |
ResourceResolver API
Almost all Sling content access flows through a ResourceResolver. It resolves URLs to resources, reads and writes content, and maps between public and internal paths. Getting it correctly is a frequent source of bugs, so this section is worth reading carefully.
You obtain a resolver from the ResourceResolverFactory. The critical rule is how you obtain it: always use a dedicated service user, never the long-deprecated admin resolver. A service user is granted exactly the permissions it needs and nothing more.
@Reference
private ResourceResolverFactory resolverFactory;
public void readContent() throws LoginException {
Map<String, Object> authInfo =
Map.of(ResourceResolverFactory.SUBSERVICE, "mysite-content-reader");
// try-with-resources guarantees the resolver is closed
try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(authInfo)) {
Resource page = resolver.getResource("/content/site/en/page");
ValueMap props = page.getValueMap();
// ... read, and for writes: resolver.commit();
}
}
A few methods and habits define correct usage:
getResource(path)returns the resource at a repository path, ornullif it doesn't exist or you lack access.resolve(request, path)runs full URL resolution, applying mappings — use it when you need the resource a request points to.map(path)does the reverse, turning an internal path into its public URL.commit()persists any changes you've made; nothing is saved until you call it.refresh()discards uncommitted in-memory changes.- Always close the resolver — a try-with-resources block is the cleanest way. Leaked resolvers are a classic AEM memory leak.
Important: Never use
getAdministrativeResourceResolveror an adminSession. They run with full repository rights, which is both a security hole and an availability risk. Define a service user (via repoinit) and a service-user mapping, and usegetServiceResourceResolver.
Sling Adaptables
The adaptable pattern is the quiet glue that holds Sling together. Many Sling objects implement Adaptable, meaning you can ask them to convert themselves into a different, related type via adaptTo(). This is how you move between abstraction layers without tight coupling.
Node node = resource.adaptTo(Node.class); // Resource → JCR Node
ValueMap vm = resource.adaptTo(ValueMap.class); // Resource → properties
Page page = resource.adaptTo(Page.class); // Resource → AEM Page
Session session = resolver.adaptTo(Session.class); // Resolver → JCR Session
ArticleModel model = resource.adaptTo(ArticleModel.class); // Resource → Sling Model
An adaptTo() call returns null when the adaptation isn't possible — for example, adapting a non-JCR resource to a Node — so always null-check the result. Behind the scenes, each adaptation is provided by an AdapterFactory, and you can register your own to make your types adaptable too. In fact, Sling Models are themselves built on this mechanism: declaring a @Model registers an adapter factory so that resource.adaptTo(YourModel.class) works. Recognizing that connection demystifies a lot of Sling at once.
Sling Context-Aware Configuration
A recurring need on real projects is configuration that varies by content path rather than globally — a different API key per brand, a different feature flag per region. A single global OSGi configuration can't express that, and Context-Aware Configuration (CA Config) is Sling's answer.
You define configuration as an annotated interface, the same way you define an OSGi @ObjectClassDefinition, but with the CA Config @Configuration annotation:
@Configuration(label = "Site Settings")
public @interface SiteConfig {
@Property(label = "Analytics ID")
String analyticsId() default "";
@Property(label = "Feature: search enabled")
boolean searchEnabled() default false;
}
Content trees declare which configuration context they belong to via a sling:configRef property (typically pointing into /conf). Your code then resolves the configuration for a given resource, and Sling walks up the tree to find the nearest applicable value:
ConfigurationBuilder builder = resource.adaptTo(ConfigurationBuilder.class);
SiteConfig config = builder.as(SiteConfig.class);
String analyticsId = config.analyticsId(); // resolved for THIS content path
The key idea is contextual resolution: the same code returns the brand-A value under /content/brand-a and the brand-B value under /content/brand-b, with no conditionals. If this sounds familiar, it's because editable-template policies are built on top of CA Config — the same mechanism, applied to component configuration.
Cheat sheet
| Concept | Key API / annotation | Use it for |
|---|---|---|
| Resource resolution | sling:resourceType, search paths | URL → content → script |
| Resource vs Node | Resource + getValueMap() | Provider-agnostic content access |
| Sling Models | @Model, @ValueMapValue, @ChildResource | Content → typed Java |
| Servlets | @SlingServletResourceTypes | HTTP endpoints (AJAX/JSON) |
| Filters | @SlingServletFilter(scope=…) | Cross-cutting request logic |
| Scheduler | Runnable + scheduler.expression | Periodic, per-instance tasks |
| Jobs | JobManager.addJob, JobConsumer | Guaranteed, once-only work |
| ResourceResolver | getServiceResourceResolver, commit(), close | Content access + persistence |
| Adaptables | adaptTo(Type.class) | Convert between related types |
| CA Config | @Configuration, sling:configRef | Per-content-path configuration |
Best practices
- ✅ Code against
ResourceandValueMap; drop to JCRNodeonly when a JCR feature demands it. - ✅ Bind servlets to resource types, not paths, and extend
SlingSafeMethodsServletunless you write. - ✅ Use a service user with
getServiceResourceResolver, and always close the resolver (try-with-resources). - ✅ Use the Scheduler for periodic, idempotent work and Sling Jobs for guaranteed, once-only work.
- ✅ Null-check every
adaptTo()result. - ✅ Use CA Config for per-site/per-brand settings instead of multiplying global OSGi configs.
Do's and Don'ts
Do
- ✅ Inspect resolution with the Recent Requests console when rendering goes wrong.
- ✅
commit()writes explicitly and handlePersistenceException. - ✅ Keep
COMPONENT-scoped filters extremely lightweight.
Don't
- ❌ Don't use the admin resource resolver or admin session — ever.
- ❌ Don't leak resource resolvers by forgetting to close them.
- ❌ Don't rely on the Scheduler for once-only work on a cluster — it runs on every instance.
- ❌ Don't bind servlets to broad
/binpaths without Dispatcher allow-listing. - ❌ Don't assume
adaptTo()succeeds — it can returnnull.
Wrapping up
Apache Sling is the framework that makes AEM what it is. Once you can trace a request through resolution to a resource and a script, reach for Resource over Node, expose endpoints with resource-type-bound servlets, choose correctly between the Scheduler and Jobs, manage a ResourceResolver safely, and lean on adaptables and CA Config, you're working with the platform instead of against it. Every higher-level AEM feature — components, templates, headless delivery — is just Sling applied.
Keep building: the Component Development guide puts Models, servlets, and resolution to work; the Annotations reference details every annotation used above; and the AEM Developer Cheat Sheet places Sling in the wider stack.
Subscribe to the Newsletter
Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.