N
Naveenr.dev
Chapter 23
30 min read•2026-07-19

Servlets in AEM — Request Resolution, Security, and Production Architecture

Understand how Sling Servlets fit into AEM architecture, how Sling resolves servlet requests, resource type vs path registration, secure repository access, Dispatcher considerations, and production troubleshooting.

Introduction

A frontend application needs to fetch data from AEM.

A form needs to submit information.

An internal application needs to trigger an operation.

Or a business team asks for an endpoint that creates or updates content in the repository.

A common developer reaction is:

"Let's create a servlet."

Sometimes that is exactly what we need. But creating the servlet is usually the easy part.

The more important questions come before the implementation.

Who is going to call the endpoint? Is it read-only, or does it change repository content? Should it be available publicly or only to authenticated users? Which repository permissions should the operation have? Should the response be cached? And does this operation actually belong behind an AEM servlet?

These decisions become much more important once the endpoint moves beyond local development.

A servlet that works correctly on an SDK can still fail in production because the request does not resolve to the servlet, Dispatcher blocks it, the caller does not have the required authorization, CSRF protection rejects the request, or the repository operation runs with the wrong security context.

So this chapter is not only about writing doGet() or doPost().

The important part is understanding where a Sling Servlet sits in AEM architecture and what happens around it.

Where Servlets Fit in AEM Architecture

A Sling Servlet is an HTTP entry point into server-side logic running inside AEM.

At the simplest level, a client sends an HTTP request, the servlet processes it, and AEM returns a response.

In a production environment, there is usually more happening around that servlet.

Where a Servlet Fits in AEM Architecture.
Where a Servlet Fits in AEM Architecture.

A request may first pass through a CDN and Dispatcher before reaching AEM Publish. Sling then has to resolve the request and determine which servlet or script should handle it. The servlet may call an OSGi service, and that service may read from the repository, modify content, or communicate with another system.

That means the servlet should have a clear responsibility.

It should deal with the HTTP boundary: read the request, validate request-specific input, call the appropriate backend service, and convert the result into an HTTP response with the correct status code.

The servlet should not automatically become the place where the entire business operation is implemented.

Consider a page-creation endpoint.

The first implementation might read parentPath, pageName, and pageTitle from the request and create the page directly inside doPost().

That works for a small implementation.

But later the same operation may need validation rules, metadata updates, repository checks, workflow integration, or calls to another system. If all of that remains inside the servlet, the HTTP layer and the business logic become tightly coupled.

A better boundary is:

Servlet

  • Handles the HTTP request and response
  • Validates request-specific input
  • Determines whether the request can proceed
  • Calls the backend service
  • Maps the result to an HTTP status and response

OSGi Service

  • Implements the business operation
  • Performs repository work where required
  • Integrates with other services or systems
  • Exposes logic that can be reused by other AEM processes

If page creation lives in a PageCreationService, the servlet can call that service today. A workflow, Sling Job, scheduler, or another OSGi service can reuse the same operation later without depending on an HTTP endpoint.

It also gives us a cleaner testing boundary. Servlet tests can focus on request handling and HTTP responses, while service tests can focus on the actual business behavior.

The boundary we want to keep throughout this chapter is:

A servlet is the HTTP entry point to an operation, not the entire implementation of that operation.

How Sling Resolves a Servlet Request

One of the most important things to understand about Sling Servlets is that not every request is treated as a direct servlet URL.

Sling is resource-oriented.

Consider this request:

GET /content/mycompany/us/en/products.list.json

Before thinking about the servlet, Sling first needs to understand the resource represented by the request.

The request can be broken down into:

  • Resource path: /content/mycompany/us/en/products
  • Selector: list
  • Extension: json
  • HTTP method: GET

Assume the resource at:

/content/mycompany/us/en/products

resolves with this resource type:

myproject/components/page

At this point, two different resolution steps are involved.

Resource resolution determines which Sling resource the request represents.

Servlet resolution determines which servlet or script should process that request.

For a resource-type-based servlet, Sling can use the resolved resource type together with request properties such as the selector, extension, and HTTP method to find the appropriate servlet.

For example, a servlet may be registered for:

text
Resource Type: myproject/components/page
Selector:      list
Extension:     json
Method:        GET
Sling Servlet Resolution Flow — how Sling resolves a resource and uses its resource type together with selectors, extension, and HTTP method during servlet resolution.
Sling Servlet Resolution Flow — how Sling resolves a resource and uses its resource type together with selectors, extension, and HTTP method during servlet resolution.

This distinction becomes important when debugging.

A servlet can be deployed correctly, its OSGi component can be active, and the Java code can be perfectly fine — but the servlet may still never execute.

For example, the request may resolve to a resource whose sling:resourceType does not match the servlet registration.

Or the resource type may match, but the request uses a different selector:

text
/content/mycompany/us/en/products.details.json

when the servlet expects:

text
list

The same problem can happen with the extension or HTTP method.

So when a resource-type-based servlet is not executing, checking only whether the bundle and component are active is not enough.

Check the actual request:

  • What resource did Sling resolve?
  • What is its resource type?
  • Which selectors are present?
  • What is the extension?
  • Which HTTP method is being used?

The servlet is selected from the request Sling actually receives, not from the request we expected the client to send.

Resource-Type vs Path-Based Servlets

Once we understand servlet resolution, the next decision is how the servlet should be registered.

In AEM projects, we commonly work with two approaches:

  • Resource-type-based servlets
  • Path-based servlets

Both are valid, but they represent different endpoint designs.

Resource-Type-Based Servlet

A resource-type-based servlet is associated with the resource type of the requested resource.

For example:

java
@Component(service = Servlet.class)
@SlingServletResourceTypes(
    resourceTypes = "myproject/components/page",
    selectors = "list",
    extensions = "json",
    methods = HttpConstants.METHOD_GET
)
public class PageListServlet extends SlingSafeMethodsServlet {
}

A request might look like:

text
GET /content/mycompany/us/en/products.list.json

Sling resolves /content/mycompany/us/en/products to a resource and uses information such as its resource type, the list selector, the json extension, and the GET method during servlet resolution.

If that resource has:

text
sling:resourceType = myproject/components/page

the servlet registration can match the resource type together with the selector, extension, and HTTP method.

This fits naturally with Sling's resource-oriented architecture because the operation belongs to an existing resource.

In this example, list.json is another representation or operation associated with the page resource. We are not inventing a completely unrelated URL just to execute Java code.

Resource-type registration is a strong fit when the endpoint's behavior is naturally connected to a Sling resource.

Path-Based Servlet

A path-based servlet takes a different approach.

Instead of being selected from the resource type of existing content, the servlet is registered against a specific path.

For example:

java
@Component(service = Servlet.class)
@SlingServletPaths(
    value = "/bin/myproject/process"
)
public class ProcessServlet extends SlingAllMethodsServlet {
}

The client calls:

text
/bin/myproject/process

Here, the endpoint itself represents the operation.

This can make sense when the operation does not naturally belong to an existing content resource.

For example, an internal application may need to trigger a controlled backend operation that is not a representation of a page, component, asset, or another Sling resource.

That does not mean every custom backend operation should automatically become a /bin servlet.

A path-based endpoint still needs an intentional design around:

  • authentication and authorization
  • allowed HTTP methods
  • input validation
  • CSRF protection where applicable
  • Dispatcher exposure
  • repository permissions
  • whether AEM should expose the operation at all

A useful rule is:

Prefer resource-type registration when the operation naturally belongs to an existing Sling resource. Consider path registration when the operation genuinely exists independently of a content resource.

/bin should not become a dumping ground for unrelated backend operations simply because path-based registration is easy.

How Do You Choose?

Start with the resource rather than the annotation.

If the requirement is:

"Give me another representation or operation for this AEM resource."

resource-type registration usually fits the Sling model better.

If the requirement is:

"Expose this independent backend operation through HTTP."

a dedicated path may be appropriate.

For example:

RequirementRegistration Approach
Return JSON associated with an existing pageResource type
Behavior depends on the requested component or resourceResource type
Selector-based representation of existing contentResource type
Operation has no natural content resourcePath may be considered
Dedicated internal operational endpointPath may be considered with appropriate security and exposure controls

This is not a rule that every AEM implementation must follow.

The important part is that the registration should reflect what the endpoint represents.

Don't choose a path-based servlet simply because /bin/myproject/... feels easier to call. And don't force an operation onto a resource type when the operation has no meaningful relationship with that resource.

The endpoint design should make sense before we choose the annotation.

GET vs POST — The HTTP Method Is Part of the Contract

Choosing the servlet registration is only part of the endpoint design.

The HTTP method also needs to describe what the operation actually does.

Consider this endpoint:

GET /bin/createPage?parentPath=/content/mycompany/us/en

If calling that URL creates a page in AEM, the request is changing server state.

That should not be exposed as a GET operation.

GET is intended for retrieving a representation without changing server state. Clients, browsers, monitoring tools, crawlers, caches, and other infrastructure can treat GET requests as safe reads. We should not design an endpoint where simply requesting a URL creates or modifies repository content.

For our page-creation requirement, a better contract is:

POST /bin/myproject/pages

The request contains the information required to create the page, while the HTTP method makes it clear that this is a state-changing operation.

A useful starting point is:

HTTP MethodTypical Purpose
GETRetrieve data without changing server state
POSTCreate a resource or trigger a state-changing operation
PUTCreate or replace a resource at a known URI, where the API design supports it
DELETERemove a resource, where the API design supports it

The exact method depends on the API contract, but the important rule is simple:

A read request should not unexpectedly change repository state.

For example:

GET /content/mycompany/us/en/products.list.json

is a reasonable read operation. The client is asking for information associated with an existing resource.

Our page-creation endpoint is different:

POST /bin/myproject/pages

The server now knows that the request represents an operation that can change state.

That also affects the rest of the endpoint design. The request needs appropriate authentication and authorization, input validation, applicable CSRF protection, and a clear response contract.

A successful creation might return:

201 Created

Invalid input might return:

400 Bad Request

An unauthorized operation should fail before the repository change is performed.

The HTTP method is therefore not just a servlet configuration detail. It is part of the contract between the caller and AEM.

Building a Production-Ready Servlet

Now we can continue with the same requirement.

An authenticated internal application needs to create a page in AEM through:

POST /bin/myproject/pages

At this point, it is tempting to put the entire implementation inside doPost():

  • Read the parameters
  • Validate them
  • Obtain a ResourceResolver
  • Adapt to PageManager
  • Create the page
  • Update metadata
  • Commit the changes
  • Build the response

That may work, but now the servlet owns both the HTTP contract and the business operation.

Instead, keep the servlet focused on the request and delegate page creation to a service.

For example:

java
@Component(service = Servlet.class)
@SlingServletPaths("/bin/myproject/pages")
public class CreatePageServlet extends SlingAllMethodsServlet {

    private static final Logger LOG =
            LoggerFactory.getLogger(CreatePageServlet.class);

    @Reference
    private PageCreationService pageCreationService;

    @Override
    protected void doPost(
            SlingHttpServletRequest request,
            SlingHttpServletResponse response) throws IOException {

        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String parentPath = request.getParameter("parentPath");
        String pageName = request.getParameter("pageName");
        String pageTitle = request.getParameter("pageTitle");

        if (StringUtils.isAnyBlank(parentPath, pageName, pageTitle)) {
            response.setStatus(SlingHttpServletResponse.SC_BAD_REQUEST);
            writeJsonResponse(
                    response,
                    Map.of("error", "Missing required parameters")
            );
            return;
        }

        try {
            String pagePath = pageCreationService.createPage(
                    parentPath,
                    pageName,
                    pageTitle
            );

            response.setStatus(SlingHttpServletResponse.SC_CREATED);
            writeJsonResponse(
                    response,
                    Map.of("path", pagePath)
            );

        } catch (PageCreationException e) {
            LOG.error(
                    "Unable to create page under parent path: {}",
                    parentPath,
                    e
            );

            response.setStatus(
                    SlingHttpServletResponse.SC_INTERNAL_SERVER_ERROR
            );

            writeJsonResponse(
                    response,
                    Map.of("error", "Unable to create page")
            );
        }
    }

    private void writeJsonResponse(
            SlingHttpServletResponse response,
            Map<String, String> payload) throws IOException {

        // Serialize with the JSON library used by the project.
        // Avoid building JSON responses through string concatenation.
    }
}

The important part of this example is the boundary.

The servlet knows about HTTP:

  • Request parameters
  • Validation
  • HTTP status codes
  • Response format

It does not know how a page is actually created.

That responsibility belongs to:

PageCreationService

The service can handle the implementation details required by the operation, such as obtaining the appropriate repository access, adapting to PageManager, applying page-creation rules, updating metadata, and handling repository failures.

This separation becomes more useful as the application grows.

Today, page creation may be triggered by the servlet.

Later, the same operation might be triggered by a workflow, Sling Job, scheduled process, event handler, or another OSGi service.

Those callers should not need to invoke a servlet to reuse page-creation logic.

They should be able to call the same service.

The architecture becomes:

HTTP request → Servlet → PageCreationService → Repository

The servlet owns the HTTP contract.

The service owns the business operation.

There is still one important question left.

When PageCreationService writes to the repository, whose permissions should it use?

Should it perform the operation using the authenticated caller's repository permissions?

Or should it use a dedicated service identity with controlled permissions?

That decision determines how we obtain the ResourceResolver.

Repository Access and ResourceResolvers

Our servlet now delegates page creation to PageCreationService.

The next question is not simply:

"How do we get a ResourceResolver?"

The more important question is:

"Which security context should perform the repository operation?"

There are two different cases.

The operation may need to run with the permissions of the authenticated caller.

Or it may need to run as a controlled backend service identity.

Those are different security models.

Using the Request ResourceResolver

Every Sling request gives us access to its ResourceResolver:

java
ResourceResolver resolver = request.getResourceResolver();

This resolver is associated with the security context of the incoming request.

If an authenticated author calls the servlet, repository operations performed through that resolver are subject to the permissions available to that request.

That can be exactly what we want.

Suppose an author triggers an operation that updates a page. If the operation should succeed only when that author already has permission to modify the page, using the request resolver preserves that permission boundary.

There is also an important lifecycle rule:

Do not close the ResourceResolver obtained from request.getResourceResolver().

The resolver belongs to the Sling request lifecycle. The servlet can use it, but it does not own it.

Using a Service ResourceResolver

Some backend operations should not depend on the repository permissions of the caller.

Our page-creation service is a good example.

Assume the application has a controlled backend process that is allowed to create pages only under:

/content/mycompany

The service can perform that repository operation using a dedicated service identity.

A service resolver can be obtained through ResourceResolverFactory using a configured subservice:

java
Map<String, Object> authInfo = Collections.singletonMap(
        ResourceResolverFactory.SUBSERVICE,
        "page-writer"
);

try (ResourceResolver resolver =
        resourceResolverFactory.getServiceResourceResolver(authInfo)) {

    // Perform the repository operation
}

Unlike the request resolver, this resolver is obtained by our application code.

That means our code owns its lifecycle and should close it when the operation is complete. A try-with-resources block makes that ownership explicit.

The service user behind page-writer should receive only the repository permissions required by the operation.

If the service creates pages only under a specific project path, there is no reason to give it broad administrative access across the repository.

This is the principle of least privilege:

Give the service identity only the permissions required to perform its responsibility.

Request Resolver or Service Resolver?

The decision is easier when we start with the security requirement rather than the API.

QuestionRequest ResourceResolverService ResourceResolver
Security contextIncoming requestConfigured service identity
Obtained fromrequest.getResourceResolver()ResourceResolverFactory
PermissionsRequest/caller security contextService-user permissions
Lifecycle ownerSling requestApplication code
Close it ourselves?NoYes
Typical useOperation should respect caller permissionsControlled backend repository operation

Ask:

Should this operation execute using the caller's repository permissions?

If yes, the request resolver may be appropriate.

If instead the requirement is:

Should this backend operation execute using a controlled application identity with explicitly assigned repository permissions?

then a service resolver may be appropriate.

But there is an important security boundary here.

Suppose page-writer has permission to create pages under:

/content/mycompany

That tells us what the backend service identity is allowed to do.

It does not tell us who is allowed to call:

text
POST /bin/myproject/pages

Those are separate decisions.

A service user controls the repository permissions of the backend operation.

It does not automatically authorize the HTTP caller to trigger that operation.

That brings us to servlet security.

Servlet Security

A servlet is an HTTP entry point into AEM.

Once an endpoint can read protected information, modify content, or trigger privileged backend logic, security cannot be treated as something we add after the servlet works.

Continue with our page-creation endpoint:

POST /bin/myproject/pages

Assume PageCreationService uses the page-writer service identity and that identity has permission to create pages under:

text
/content/mycompany

The repository side may now be correctly restricted.

But imagine that any caller who can reach the servlet is allowed to trigger that service.

We have protected what the service user can modify, but we have not protected who can invoke the operation.

Authentication and Authorization Are Different Decisions

Authentication answers:

Who is making this request?

Authorization answers:

Is that caller allowed to perform this operation?

An authenticated AEM user should not automatically gain access to every custom servlet.

For a state-changing endpoint, the application needs a clear authorization rule before privileged backend logic is executed.

This becomes especially important when the servlet delegates to a service user.

The caller may have very limited repository permissions while the backend service identity has permission to perform a more privileged operation.

That design can be valid, but only when the caller is intentionally authorized to trigger it.

Think of the two boundaries separately:

HTTP boundary

Can this caller invoke the operation?

Repository boundary

What is the backend service identity allowed to modify?

Both need to be correct.

Validate the Operation, Not Just the Parameters

Authentication and authorization are not enough.

The servlet also controls what input reaches the backend operation.

Suppose the request accepts:

text
parentPath=/content/mycompany/us/en

If the servlet accepts any arbitrary repository path, a caller may try:

text
/content/other-project
/apps
/libs

Even if repository permissions eventually reject some of those requests, the endpoint should enforce its own business boundary.

If the servlet exists only to create pages under:

text
/content/mycompany

validate that explicitly before calling the service.

Input validation can include:

  • Required parameters
  • Allowed repository paths
  • Expected formats
  • Allowed values
  • Request size where relevant

Do not rely on frontend validation for these rules.

The servlet is an HTTP endpoint and can be called directly without the intended frontend.

CSRF and State-Changing Requests

Our endpoint uses POST because it changes server state.

That also means the request needs to work with the security controls applicable to that environment, including AEM's CSRF protection where applicable.

A common mistake during development is to see a 403, disable a security control, and continue until the request works.

That hides the actual problem.

A state-changing request can cross several independent security boundaries:

  • Authentication
  • Authorization
  • CSRF protection
  • Dispatcher filtering
  • Repository permissions

If the request is rejected, first determine which layer rejected it.

A Dispatcher rejection is not fixed by changing repository permissions.

A repository permission failure is not fixed by weakening CSRF protection.

And a service user with write access does not mean the HTTP caller is authorized to trigger that write.

For servlet security, the useful question is not simply:

"Can this endpoint execute?"

It is:

"Who can execute it, what can they ask it to do, and under which repository identity will that operation run?"

Dispatcher and Caching

A servlet can work perfectly when called directly on AEM Publish and still fail through the public URL.

That does not automatically mean something is wrong with the servlet.

In a production environment, the request may pass through additional layers before it reaches Sling.

Dispatcher Request Flow — showing how client requests pass through the CDN and Dispatcher, where they may be served from cache, blocked, or forwarded to AEM Publish and the Sling Servlet.
Dispatcher Request Flow — showing how client requests pass through the CDN and Dispatcher, where they may be served from cache, blocked, or forwarded to AEM Publish and the Sling Servlet.

For a simplified request path, think about:

Client → CDN → Dispatcher → AEM Publish → Sling → Servlet

Dispatcher is therefore part of the servlet's production boundary.

Before a request ever reaches the servlet, Dispatcher configuration can determine whether that URL, HTTP method, selector, and extension are allowed to reach Publish.

Consider our read-only endpoint:

GET /content/mycompany/us/en/products.list.json

The servlet may be registered correctly and work when Publish is called directly.

But through Dispatcher, the request also has to match the rules intentionally exposed by the application.

Now consider our state-changing endpoint:

POST /bin/myproject/pages

This endpoint has a very different purpose.

It is not a content representation that we want infrastructure to treat like normal cacheable page delivery. It triggers a backend operation.

If that endpoint needs to be reachable through Dispatcher, the exposure should be intentional. The URL and required HTTP method should be allowed only as required by the application design and protected by the authentication and authorization controls we discussed earlier.

Caching Is Also Part of the Endpoint Design

A GET servlet response is not automatically cacheable simply because it uses GET.

For example:

GET /content/mycompany/us/en/products.list.json

may be a good caching candidate if the response represents content that can safely be reused between requests.

Whether it is actually cached depends on the surrounding CDN and Dispatcher configuration, URL structure, cache rules, response behavior, and application requirements.

That decision matters because the response may contain:

  • Public content that is safe to reuse
  • Frequently changing content
  • User-specific information
  • Data whose freshness requirements make caching inappropriate

So cache behavior should come from the endpoint contract, not simply from the fact that the servlet implements doGet().

For our state-changing endpoint:

POST /bin/myproject/pages

the request represents an operation, not a cached content representation.

The endpoint design, HTTP method, Dispatcher exposure, authentication model, and caching behavior should all describe the same intent.

A Useful Production Debugging Boundary

One of the most useful servlet checks in production is simple.

If an endpoint fails through the public URL, test whether the same request reaches and works on the expected AEM environment through the appropriate diagnostic path available to your team.

If the servlet works at the AEM layer but not through the normal public route, investigate the layers in front of AEM before changing the servlet implementation.

The request may be blocked because of:

  • URL filtering
  • HTTP method filtering
  • Selector or extension restrictions
  • CDN or proxy behavior
  • Authentication or routing configuration

The important distinction is:

A servlet working inside AEM does not prove that the endpoint is correctly exposed through the complete production request path.

This is why servlet troubleshooting should follow the request from the outside in rather than starting and ending with the Java class.

Production Troubleshooting

When a servlet fails in an AEM environment, the Java class is only one place to investigate.

The failure can happen before Sling resolves the servlet, while the servlet is processing the request, when the service accesses the repository, or before the request reaches AEM at all.

A useful debugging approach is to follow the same path the request follows.

Servlet Returns 404

A 404 does not necessarily mean the servlet is missing.

For a resource-type-based servlet, start with the actual request.

Suppose the servlet expects:

GET /content/mycompany/us/en/products.list.json

Verify:

  • The resource /content/mycompany/us/en/products exists
  • The resolved resource has the expected sling:resourceType
  • The list selector matches the servlet registration
  • The json extension matches
  • The HTTP method matches
  • The servlet component is registered and active

A servlet can be deployed correctly and still never be selected by Sling.

For path-based servlets, verify that the requested path matches the registered servlet path and that the endpoint is available in the target environment.

The useful distinction is:

A deployed servlet is not necessarily a resolved servlet.

Works on Author but Not on Publish

When the same servlet behaves differently between Author and Publish, compare the environment around the code before assuming the servlet implementation is different.

Check whether:

  • The bundle and servlet component are available on Publish
  • The requested content exists on Publish
  • Required OSGi configuration is present
  • Required service-user mappings are available
  • The service user has the expected repository permissions
  • The request is reaching the expected Publish environment

Author and Publish can contain different content, configuration, permissions, and runtime conditions.

The same Java implementation can therefore produce different results depending on the environment in which it runs.

Works in AEM but Not Through the Public Route

If the servlet works at the AEM layer but fails through the application's normal public route, move outward in the request path.

Check the infrastructure in front of AEM.

For example:

  • Is the URL pattern allowed?
  • Is the HTTP method allowed?
  • Are the required selectors and extensions permitted?
  • Is authentication handled as expected?
  • Is a CDN, proxy, or Dispatcher rule affecting the request?

Do not change servlet code until you know the request is actually reaching Sling.

If the request is blocked before AEM, changing doGet() or doPost() will not fix the problem.

POST Returns 403

A 403 is especially easy to misdiagnose because several layers can reject a state-changing request.

For our example:

POST /bin/myproject/pages

the rejection could come from:

  • Authentication
  • Authorization
  • Applicable CSRF protection
  • Dispatcher filtering
  • Repository permissions

The HTTP status alone does not tell us which architectural layer is responsible.

Find where the request stops.

If Dispatcher rejects the request, investigate the exposure rules.

If AEM security rejects the caller, investigate authentication or authorization.

If the servlet reaches PageCreationService but the repository operation fails, investigate the repository security context and permissions.

Avoid disabling security controls simply to make the request succeed.

Fix the layer that is actually rejecting the operation.

Service User Cannot Modify Content

Suppose the servlet executes and PageCreationService obtains a service resolver, but page creation still fails.

Now the problem is further down the request path.

Check:

  • Is the expected subservice mapping configured?
  • Is the expected service identity being resolved?
  • Does that service user have the required permissions?
  • Are those permissions applied to the correct repository path?
  • Does the operation require permissions that were not included in the original design?

Do not solve the problem by immediately granting broad repository access.

If page-writer only needs to create content under a defined project path, its permissions should remain limited to what that operation requires.

Wrong Servlet Is Executed

Sometimes the request reaches Sling, but a different servlet or script handles it.

For resource-type-based servlet resolution, compare the actual request with the registration:

  • Resource type
  • Selector
  • Extension
  • HTTP method

A small difference can change the resolution result.

For example, these requests do not represent the same servlet-resolution input:

/content/mycompany/us/en/products.list.json

/content/mycompany/us/en/products.details.json

If the servlet is registered for the list selector, changing the selector changes the request Sling needs to resolve.

When the wrong servlet executes, debug the request Sling actually received rather than the servlet you expected Sling to choose.

Follow the Failure Through the Architecture

Most servlet problems become easier to reason about when we stop treating the servlet as an isolated Java class.

Start with the symptom and move through the layers:

SymptomFirst Areas to Investigate
404 on resource-type endpointResource resolution and servlet resolution
Works on Author, fails on PublishContent, configuration, deployment, permissions
Works at AEM layer, fails through public routeCDN, Dispatcher, routing, authentication
POST returns 403Authentication, authorization, CSRF, Dispatcher
Servlet executes but repository write failsResolver security context and repository permissions
Wrong servlet executesResource type, selector, extension, HTTP method

The goal is to identify which layer failed before changing the implementation.

Architect Perspective

From an architecture point of view, the first question is not:

"How should we implement this servlet?"

It is:

"Why does this operation need an HTTP endpoint in AEM?"

Once that is clear, the servlet design can be reviewed through four boundaries.

1. Endpoint Boundary

Start with the reason the endpoint exists.

Who needs to call it?

Is the caller a frontend application, another system, an authenticated AEM user, or an internal process?

Then determine what the endpoint represents.

A request such as:

GET /content/mycompany/us/en/products.list.json

represents data associated with an existing Sling resource.

A request such as:

POST /bin/myproject/pages

represents an explicit backend operation.

Those are different endpoint designs and should not be treated as interchangeable simply because both can be implemented using a servlet.

Before writing the servlet, the HTTP contract should already be clear:

  • What operation is exposed?
  • Who needs to call it?
  • Which HTTP method represents it?
  • Should it be externally reachable at all?
  • Is its response cacheable?

2. Sling Resolution Boundary

If the endpoint belongs to an existing Sling resource, resource-type registration usually fits naturally with Sling's resource-oriented model.

The request can then be resolved using the resource together with properties such as the selector, extension, and HTTP method.

If the operation exists independently of a content resource, a dedicated path may be appropriate.

The decision should come from what the endpoint represents, not from which servlet annotation is easier to configure.

This also defines how the endpoint will be debugged later.

A resource-type servlet requires us to reason about resource resolution and servlet resolution.

A path-based servlet requires us to reason about the registered endpoint path and how that path is exposed through the surrounding infrastructure.

3. Security Boundary

A servlet that triggers repository operations has two security questions.

First:

Who is allowed to trigger the operation?

Second:

Under which repository identity will the operation execute?

Those questions must remain separate.

Authentication and authorization control access to the HTTP operation.

The request resolver or service resolver determines the repository security context used by the backend operation.

A service user with permission to modify content does not automatically authorize the caller to trigger that modification.

Likewise, an authenticated caller does not automatically mean the backend operation should execute with broad repository permissions.

The endpoint and repository security models need to be designed together without collapsing them into the same decision.

4. Logic Boundary

Finally, decide where the actual business operation belongs.

The servlet should own the HTTP contract:

  • Request handling
  • Request-specific validation
  • HTTP response
  • Status codes

Reusable business logic should live behind that boundary.

For our example:

POST /bin/myproject/pages

the servlet receives and validates the request.

PageCreationService owns the page-creation operation.

That separation means the same business operation can later be reused by another OSGi service, workflow, Sling Job, scheduler, or event-driven process without going through an HTTP endpoint.

It also prevents servlet classes from becoming large backend implementations that happen to start with doPost().

The Final Architecture Question

After defining those four boundaries, there is still one question worth asking:

"Is AEM actually the right system to perform this operation?"

Just because an operation can be implemented as an AEM servlet does not mean it should be.

If the operation primarily exists to work with AEM content, repository resources, or AEM-specific services, exposing it through AEM may make sense.

If AEM is only being used as a convenient place to host unrelated backend logic, the responsibility may belong somewhere else in the system architecture.

The servlet should exist because AEM has a clear responsibility in the operation — not simply because adding another endpoint is technically possible.

Summary

A Sling Servlet is only one part of the request architecture.

The important decisions happen around it.

Keep these principles in mind:

  1. Treat the servlet as an HTTP boundary. Keep reusable business logic in services rather than building large servlet implementations.

  2. Understand how Sling resolves the request. Resource type, selector, extension, and HTTP method can determine whether a resource-type servlet is selected.

  3. Choose the endpoint design intentionally. Resource-type and path-based servlets solve different problems, and the HTTP method should reflect what the operation actually does.

  4. Separate caller access from repository access. Authorization to invoke an endpoint and permission to modify the repository are different security decisions.

  5. Debug the complete request path. A failure may happen at the CDN, Dispatcher, Sling resolution, servlet, service, or repository layer.

A servlet that works locally is only the starting point.

A production-ready servlet is one whose HTTP contract, Sling resolution, security model, repository identity, service boundary, and external exposure all agree with the operation it is supposed to perform.

The architecture in this chapter becomes easier to understand when we look at how servlet decisions behave in actual implementations.

Cleaning Up Empty DAM and Experience Fragment Nodes Using a Dynamic Servlet

Real-World Scenario: Cleaning Up Empty DAM and Experience Fragment Nodes Using a Dynamic Servlet

This scenario starts with a production requirement to remove empty folders that were accumulating under DAM and Experience Fragment paths.

The interesting part is not only the servlet implementation. The scenario shows why a servlet was selected for the operation, how the path-based endpoint was used, and how the operation was approached safely through a POC and controlled rollout.

It also covers operational considerations such as backup, authoring freeze, verification, and execution.

Where Servlets Actually Earn Their Place in AEM

Real-World Scenario — Where Servlets Actually Earn Their Place in AEM

This scenario looks at the decision from a broader architecture perspective: when a servlet is the right boundary and when another AEM mechanism may fit better.

It also covers problems that can appear around servlet design, including selector collisions, least-privilege service users, and CSRF-related gaps.

These scenarios are intentionally separate from this chapter.

Chapter 24 establishes the servlet architecture and decision model. The real-world scenarios show what those decisions look like when applied to production problems.

What's Next

Throughout this chapter, one boundary kept appearing:

The servlet handles the HTTP interaction. The OSGi service handles the reusable business operation.

So the next step is to move behind the servlet and look at that service layer in detail.

Chapter 24: OSGi Services

In the next chapter, we will look at how OSGi services fit into AEM backend architecture, including service interfaces and implementations, dependency injection, component lifecycle, configuration, service ranking, and how to design reusable backend logic without coupling it to a servlet, workflow, scheduler, or other entry point.

Enjoyed this chapter?

Get an email when I publish the next chapter. No spam — just new technical deep-dives.

Comments

Share feedback or questions about this blog post.

No comments yet. Be the first to share your thoughts.