The Apache Sling Framework: A Complete Guide for AEM Developers

16 min read

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.

AEMSlingJavaOSGiArchitectureReference

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:

  1. Resolve the resource. Sling locates the content at the path — here, /content/site/en/page.
  2. Read its sling:resourceType. This property (for example mysite/components/page) is the link between content and code.
  3. 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 specific page.mobile.html if it exists.
  4. Resolve the script's location across the search paths — by default /apps first, then /libs. This is why your overlay in /apps wins 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)
AbstractionHigh-level, provider-agnosticLow-level, JCR-only
Read propertiesgetValueMap() — type-safe, null-safe with defaultsgetProperty().getString() — verbose, throws
Backing storeJCR, file system, DB, remote (any provider)Always the JCR
RecommendationDefault choiceOnly for JCR-specific operations

Rule of thumb: prefer Resource and ValueMap. Reach for Node and 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:

  • scope decides which part of the request lifecycle the filter participates in. The values are REQUEST (once per top-level request), INCLUDE and FORWARD (for included/forwarded resources), ERROR (error handling), and COMPONENT (around every component render).
  • service.ranking controls 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 prefer REQUEST scope 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 SchedulerSling Jobs
TriggerTime-based (cron/period)On demand (you enqueue)
ExecutionRuns on every instanceRuns once across the cluster
PersistenceNonePersisted, survives restarts
RetriesNoneBuilt-in (JobResult.RETRY)
Use forPeriodic, idempotent tasksGuaranteed, 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, or null if 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 getAdministrativeResourceResolver or an admin Session. 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 use getServiceResourceResolver.

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

ConceptKey API / annotationUse it for
Resource resolutionsling:resourceType, search pathsURL → content → script
Resource vs NodeResource + getValueMap()Provider-agnostic content access
Sling Models@Model, @ValueMapValue, @ChildResourceContent → typed Java
Servlets@SlingServletResourceTypesHTTP endpoints (AJAX/JSON)
Filters@SlingServletFilter(scope=…)Cross-cutting request logic
SchedulerRunnable + scheduler.expressionPeriodic, per-instance tasks
JobsJobManager.addJob, JobConsumerGuaranteed, once-only work
ResourceResolvergetServiceResourceResolver, commit(), closeContent access + persistence
AdaptablesadaptTo(Type.class)Convert between related types
CA Config@Configuration, sling:configRefPer-content-path configuration

Best practices

  • ✅ Code against Resource and ValueMap; drop to JCR Node only when a JCR feature demands it.
  • ✅ Bind servlets to resource types, not paths, and extend SlingSafeMethodsServlet unless 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 handle PersistenceException.
  • ✅ 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 /bin paths without Dispatcher allow-listing.
  • ❌ Don't assume adaptTo() succeeds — it can return null.

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.

Share this article

Subscribe to the Newsletter

Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.

Back to Blog