N
Naveenr.dev
Chapter rw-03
10 min read•2026-07-22
📖 AEM - Adobe Experience Manager SeriesChapter rw-03 · 16 chapters

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

A practical look at where Sling Servlets fit in real AEM implementations, when a servlet is the right request boundary, and when the requirement belongs in a Sling Model or OSGi service instead.

Part of the Real-World AEM Problems series. For the servlet internals behind these examples, see Servlets in AEM — Request Resolution, Security, and Production Architecture.

The Question That Usually Starts This Discussion

A servlet is easy to create in AEM.

That does not mean every requirement that needs Java code should become a servlet.

I have seen requirements where the first implementation idea is:

"We need some data from AEM. Let's create a servlet."

Sometimes that is exactly the right solution.

Other times, the data already belongs to a component and could be provided by its Sling Model. In another case, the servlet becomes a thin URL around business logic that should really live in an OSGi service.

The decision becomes easier when I separate the responsibilities.

A Sling Model prepares data for a resource or component.

An OSGi service owns reusable business, integration, or repository logic.

A Sling Servlet handles an HTTP request.

That HTTP boundary is where servlets earn their place.

In real projects, I normally see that happen in three types of requirements:

  • Frontend code needs data through a separate HTTP request.
  • A client needs to submit something or explicitly trigger an operation.
  • An external system needs an endpoint exposed by AEM.

The important part is not whether AEM can expose a servlet for the requirement.

It is whether the requirement actually needs its own HTTP endpoint.

Case 1 — Frontend Needs Data Outside Page Rendering

One place where I would consider a servlet is when frontend code needs data through a separate HTTP request and that data does not naturally belong to the component rendering the page.

Consider a store locator.

The page may contain a Store Locator component, but the actual results can depend on values entered after the page has already loaded:

  • Postal code
  • City
  • Latitude and longitude
  • Search radius
  • Store type

The frontend could make a request such as:

text
GET /content/myproject/us/en/store-locator.stores.json?postalCode=10001

A resource-type servlet can handle that request, validate the search parameters, call the service responsible for retrieving store data, and return JSON to the frontend.

For example:

java
@Component(
    service = Servlet.class,
    property = {
        ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES
                + "=myproject/components/storelocator",
        ServletResolverConstants.SLING_SERVLET_SELECTORS
                + "=stores",
        ServletResolverConstants.SLING_SERVLET_EXTENSIONS
                + "=json",
        ServletResolverConstants.SLING_SERVLET_METHODS
                + "=" + HttpConstants.METHOD_GET
    }
)
public class StoreLocatorServlet extends SlingSafeMethodsServlet {

    @Reference
    private StoreLocatorService storeLocatorService;

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

        String postalCode = request.getParameter("postalCode");

        if (postalCode == null || postalCode.isBlank()) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        List<Store> stores =
                storeLocatorService.findStores(postalCode);

        // Build the JSON response from the returned store data.
    }
}

The servlet is the HTTP boundary here.

It should not contain all the logic for searching stores, calling an external store API, mapping responses, handling integration configuration, and applying business rules.

That work belongs behind something such as:

java
public interface StoreLocatorService {

    List<Store> findStores(String postalCode);
}

The servlet receives the request, validates what came from the client, delegates the work, and returns the appropriate HTTP response.

Why Not Put This in the Sling Model?

The Store Locator component may still have a Sling Model.

That model can provide the data required when the component is initially rendered:

  • Heading
  • Search placeholder
  • Default radius
  • CTA labels
  • Configuration authored on the component

But the store results are different.

They depend on input provided by the visitor after the page has loaded. Returning those results does not require rendering the component again.

That is where a separate HTTP request starts to make sense.

I would not create a servlet simply because the frontend needs JSON. If the frontend only needs the existing component state in JSON form, a Sling Model Exporter may already solve that requirement.

The servlet becomes useful when the request has its own input, processing, and response lifecycle outside the normal page render.

Case 2 — A Request Needs to Perform an Operation

Not every servlet exists to return data.

Sometimes the request itself represents an action that needs to happen in AEM.

A form submission is a common example.

Consider a newsletter form:

html
<form action="/bin/myproject/newsletter/subscribe" method="post">
    <input type="email" name="email" required />
    <button type="submit">Subscribe</button>
</form>

Submitting the form is different from rendering the component.

The component can use a Sling Model to provide the heading, description, button label, or other authored fields.

But when the visitor clicks Subscribe, AEM receives a new request that needs to:

  • Validate the submitted input
  • Trigger the subscription operation
  • Handle failures from the downstream system
  • Return an appropriate HTTP status and response

That is a reasonable servlet boundary.

For example:

java
@Component(
    service = Servlet.class,
    property = {
        ServletResolverConstants.SLING_SERVLET_PATHS
                + "=/bin/myproject/newsletter/subscribe",
        ServletResolverConstants.SLING_SERVLET_METHODS
                + "=" + HttpConstants.METHOD_POST
    }
)
public class NewsletterSubscribeServlet extends SlingAllMethodsServlet {

    @Reference
    private NewsletterService newsletterService;

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

        String email = request.getParameter("email");

        if (email == null || email.isBlank()) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        try {
            newsletterService.subscribe(email.trim());

            response.setStatus(HttpServletResponse.SC_OK);

        } catch (NewsletterServiceException e) {
            response.setStatus(
                    HttpServletResponse.SC_INTERNAL_SERVER_ERROR
            );
        }
    }
}

The servlet owns the HTTP part of the requirement.

The subscription logic does not.

I would not put CRM integration, authentication with the external service, request mapping, retry logic, or other business rules directly inside doPost().

That belongs behind the service:

java
public interface NewsletterService {

    void subscribe(String email)
            throws NewsletterServiceException;
}

This separation becomes useful quickly.

The same subscription operation might later be triggered from another AEM process. If the logic lives inside the servlet, the only way to reuse it is to duplicate the code or treat the servlet itself as the business layer.

If the logic lives in a service, the servlet remains only one entry point.

The HTTP Method Matters

Because this request performs an operation, I would use POST.

I would not expose it through:

java
GET /bin/myproject/newsletter/subscribe?email=user@example.com

GET should not be used simply because it is convenient to test from a browser.

This distinction also came up in the repository cleanup implementation. The original POC used GET to trigger deletion because it was convenient during testing. Looking at that implementation later, changing the operation to POST was one of the first design improvements.

The same rule applies here: once a request changes state or triggers an operation, its HTTP contract should reflect that behavior.

Reaching Publish Changes the Security Discussion

A form endpoint that works on Author is not automatically ready to expose on Publish.

Once the endpoint is reachable from the public-facing environment, I would review at least three separate concerns:

Who can call the endpoint?

Some operations are intentionally public, such as a newsletter subscription. Others may require an authenticated user or a restricted internal caller.

What input can the caller control?

Every request parameter should be treated as external input. Repository paths, URLs, identifiers, and other values that influence backend behavior need appropriate validation.

Under which repository identity does the operation run?

If repository access is required, that decision should be explicit. Using the request ResourceResolver and obtaining a service resolver represent different security models.

The servlet being reachable does not automatically mean the caller should be allowed to perform everything the backend code is capable of doing.

For state-changing browser requests, CSRF protection also needs to be handled according to how the endpoint is exposed and called. I would treat that as part of the endpoint design rather than something added after the servlet is already working.

Case 3 — An External System Needs an AEM Endpoint

Another place where a servlet can be the right boundary is when a system outside AEM needs to send a request into AEM.

A webhook is a good example.

Suppose an external product system owns product information and sends an event whenever a product changes. AEM needs to receive that event and trigger some processing.

The request could look like:

text
POST /bin/myproject/product-updates

with a payload such as:

java
{
  "productId": "P10045",
  "eventType": "PRODUCT_UPDATED"
}

A servlet can receive that request:

java
@Component(
    service = Servlet.class,
    property = {
        ServletResolverConstants.SLING_SERVLET_PATHS
                + "=/bin/myproject/product-updates",
        ServletResolverConstants.SLING_SERVLET_METHODS
                + "=" + HttpConstants.METHOD_POST
    }
)
public class ProductUpdateServlet extends SlingAllMethodsServlet {

    @Reference
    private ProductUpdateService productUpdateService;

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

        // Validate the incoming request and payload.

        productUpdateService.processUpdate(request.getInputStream());

        response.setStatus(HttpServletResponse.SC_ACCEPTED);
    }
}

The servlet has a small responsibility:

Receive the HTTP request, validate it, hand the work to the appropriate service, and return the HTTP response.

The servlet should not become the complete integration implementation.

For example, I would not put all of this directly inside doPost():

text
Parse payload
Validate product
Call external API
Resolve repository resources
Update AEM content
Trigger additional processing
Handle retries
Write audit information

Those responsibilities need their own design.

The servlet is only how the external system enters AEM.

Do Not Trust the Endpoint Just Because the Caller Is Another System

An integration endpoint still needs a clear authentication and authorization model.

If an external system calls:

java
POST /bin/myproject/product-updates

AEM needs a reliable way to establish that the request came from an allowed caller.

The exact mechanism depends on the integration architecture, but the servlet should not assume that knowing the URL is enough to trigger the operation.

The payload also needs validation before it reaches repository or business logic.

For example, if the request contains:

java
{
  "productId": "../../../content/dam",
  "eventType": "PRODUCT_UPDATED"
}

the backend should not blindly turn productId into a repository path or use it to construct another backend request.

External input stays external input even when the caller is another enterprise system.

Keep Long-Running Work Out of the Request

There is another boundary I would consider for webhook-style endpoints.

The external system usually needs to know whether AEM accepted the request. It does not necessarily need to wait for every downstream operation to finish.

If processing involves several repository updates, external API calls, asset processing, or other expensive work, doing everything inside the servlet keeps the HTTP request open for too long.

Instead of:

External System

  • Servlet
  • Complete all processing
  • Return response

I would prefer the servlet to validate and accept the request, then hand the longer-running work to the appropriate asynchronous mechanism.

The response can then represent that the request was accepted:

text
HTTP 202 Accepted

The servlet remains the HTTP entry point without becoming the execution engine for the complete workflow.

The choice of Sling Jobs, workflows, or another asynchronous mechanism depends on what the processing actually needs. That decision belongs to the processing design rather than the servlet itself.

This is another useful boundary for deciding whether a servlet is doing too much:

If most of doPost() is no longer about HTTP, the servlet probably owns logic that belongs somewhere else.

When I Would Not Build a Servlet

Knowing where a servlet fits also means knowing when not to create one.

A servlet gives the application another HTTP endpoint. That endpoint has to be resolved correctly, secured, exposed through the required infrastructure, tested, and maintained.

If the requirement does not need a separate HTTP request, adding a servlet usually creates an unnecessary boundary.

If the Data Belongs to Component Rendering

Consider a component that needs:

  • Title
  • Description
  • Image
  • CTA
  • Some derived value based on authored properties

That is component rendering logic.

I would keep it in the Sling Model:

java
@Model(
    adaptables = SlingHttpServletRequest.class,
    resourceType = "myproject/components/productcard",
    defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL
)
public class ProductCardModel {

    @ValueMapValue
    private String title;

    @ValueMapValue
    private String description;

    public String getTitle() {
        return title;
    }

    public String getDescription() {
        return description;
    }
}

HTL can consume the model directly:

java
<sly data-sly-use.model="com.myproject.core.models.ProductCardModel">

    <h2>${model.title}</h2>
    <p>${model.description}</p>

</sly>

I would not introduce something like:

java
GET /bin/myproject/product-card?id=123

just so the same component can make another request to AEM for data AEM already had while rendering the page.

That adds another network request and another endpoint without changing the actual responsibility.

If the Requirement Is the Component's JSON Representation

There is another case where the frontend needs JSON, but that still does not automatically mean a custom servlet.

Suppose the requirement is simply to expose the component's existing model in JSON form.

A Sling Model Exporter may already fit that requirement.

For example:

java
@Model(
    adaptables = SlingHttpServletRequest.class,
    resourceType = "myproject/components/productcard",
    adapters = ProductCard.class
)
@Exporter(
    name = ExporterConstants.SLING_MODEL_EXPORTER_NAME,
    extensions = ExporterConstants.SLING_MODEL_EXTENSION
)
public class ProductCardModel implements ProductCard {

    // Component model
}

The frontend can consume the exported representation without us creating a second endpoint with its own response structure.

This is different from the store locator example.

The store locator request had its own runtime input and processing:

text
postalCode → search → store results

The product card example is exposing the state of an existing component.

Those are different requirements even though both return JSON.

If the Code Is Really Business Logic

Another pattern I try to avoid is using a servlet as the place where all application logic lives.

A servlet might start small:

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

    // validate request
}

Then repository access gets added.

Then an external API call.

Then mapping logic.

Then business rules.

Then error handling.

Eventually doPost() becomes the implementation of the entire feature.

The presence of an HTTP request does not mean all the work belongs in the servlet.

For example:

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

    String productId = request.getParameter("productId");

    if (productId == null || productId.isBlank()) {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        return;
    }

    productService.updateProduct(productId);

    response.setStatus(HttpServletResponse.SC_OK);
}

The servlet handles the HTTP boundary.

The service handles the operation:

java
public interface ProductService {

    void updateProduct(String productId);
}

That separation also means ProductService can be reused from another AEM process without calling the servlet.

The Check I Use

Before creating a servlet, I check what actually requires the HTTP boundary.

If the requirement is:

Component needs data while rendering

I start with a Sling Model.

If it is:

Expose the component's existing model as JSON

I check whether Sling Model Exporter already fits.

If it is:

Reusable business, integration, or repository operation

I put that logic in a service.

If it is:

A client needs to make an independent HTTP request to AEM

then a servlet becomes a reasonable entry point.

The servlet may still call a Sling Model, OSGi service, repository API, or external integration behind that boundary.

Its reason for existing is the request itself.

A Practical Example — Newsletter Subscription

The earlier cases used small code samples to show where a servlet fits. This example puts the same boundaries together in one implementation.

Assume a page contains a newsletter component.

The component itself is responsible for rendering authored content such as:

  • Heading
  • Description
  • Email placeholder
  • Submit button label
  • Success and error messages

A Sling Model can handle that part.

When the visitor submits the form, however, the browser needs to send a separate request to AEM.

For this example, the endpoint is:

text
POST /bin/myproject/newsletter/subscribe

The servlet should handle the HTTP concerns and delegate the actual subscription operation.

java
@Component(
    service = Servlet.class,
    property = {
        ServletResolverConstants.SLING_SERVLET_PATHS
                + "=/bin/myproject/newsletter/subscribe",
        ServletResolverConstants.SLING_SERVLET_METHODS
                + "=" + HttpConstants.METHOD_POST
    }
)
public class NewsletterSubscribeServlet extends SlingAllMethodsServlet {

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

    @Reference
    private NewsletterService newsletterService;

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

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

        String email = request.getParameter("email");

        if (email == null || email.isBlank()) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            writeResponse(
                    response,
                    false,
                    "Email address is required"
            );
            return;
        }

        try {
            newsletterService.subscribe(email.trim());

            response.setStatus(HttpServletResponse.SC_OK);
            writeResponse(
                    response,
                    true,
                    "Subscription completed"
            );

        } catch (NewsletterServiceException e) {

            LOG.error("Newsletter subscription failed", e);

            response.setStatus(
                    HttpServletResponse.SC_INTERNAL_SERVER_ERROR
            );

            writeResponse(
                    response,
                    false,
                    "Subscription failed"
            );
        }
    }

    private void writeResponse(
            SlingHttpServletResponse response,
            boolean success,
            String message) throws IOException {

        // Serialize the response with the JSON library
        // already used by the project.
    }
}

The response-writing method is intentionally left at the serialization boundary here. I would use the JSON library already available in the project rather than manually building JSON strings inside the servlet.

The service contract remains independent of HTTP:

java
public interface NewsletterService {

    void subscribe(String email)
            throws NewsletterServiceException;
}

Its implementation can own the actual integration:

java
@Component(service = NewsletterService.class)
public class NewsletterServiceImpl
        implements NewsletterService {

    @Override
    public void subscribe(String email)
            throws NewsletterServiceException {

        // Call the newsletter or CRM integration.
    }
}

The important part is the responsibility split.

The servlet knows that the client submitted an HTTP POST. It reads and validates the request, delegates the operation, maps the result to an HTTP status, and writes the response.

NewsletterService does not need to know anything about SlingHttpServletRequest or SlingHttpServletResponse.

That keeps HTTP concerns out of the integration layer.

What Belongs in the Servlet

For this type of endpoint, I would keep the servlet limited to work such as:

  • Reading request parameters or the request body
  • Basic request validation
  • Checking request-specific authorization where required
  • Calling the appropriate service
  • Choosing the HTTP status
  • Building the response

If the servlet starts handling CRM authentication, repository traversal, retry rules, integration mapping, or other feature-specific processing, I would move that work out.

For example, this belongs at the request boundary:

java
String email = request.getParameter("email");

if (email == null || email.isBlank()) {
    response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
    return;
}

This does not:

java
String accessToken = getCrmAccessToken();
String contactId = searchCrmForContact(email);
updateMarketingPreferences(contactId);
writeSubscriptionAuditEntry(email);
retryFailedRequest();

Those operations may all be required by the feature, but they do not become servlet responsibilities simply because the feature starts with an HTTP request.

What Belongs in the Service

The service owns the operation the application is trying to perform.

For the newsletter example, that could include:

  • Building the downstream request
  • Calling the newsletter or CRM API
  • Mapping the external response
  • Applying subscription rules
  • Converting integration failures into application-level exceptions

The servlet can then remain small even if the implementation behind subscribe() becomes more involved.

This separation also changes how the code can be tested.

The service can be tested without constructing a Sling request or response.

The servlet tests can concentrate on the HTTP boundary:

  • Missing input returns the expected status
  • Valid input delegates to the service
  • Service failure returns the expected error response

The integration tests can concentrate on the service behavior separately.

The servlet and service are part of the same feature, but they solve different problems.

What Changes When the Endpoint Reaches Publish

A servlet working correctly on Author does not mean the endpoint is ready for Publish.

Once the endpoint is reachable through the public-facing environment, I review the complete request path rather than only the servlet code.

For the newsletter example, the request may eventually reach:

text
POST /bin/myproject/newsletter/subscribe

Before exposing that endpoint, I would check who is expected to call it, whether the request can reach AEM through the Dispatcher/CDN layer, how the request is protected, what input the caller controls, and what backend permissions the operation actually needs.

Public Does Not Mean Unrestricted

A newsletter form is intentionally available to anonymous visitors.

That does not mean the endpoint should accept any request and perform any operation.

The servlet should expose only the capability required for that use case.

For example, the request may accept:

text
email=user@example.com

It should not accept caller-controlled values such as:

java
repositoryPath=/content/...
serviceName=...
endpointUrl=...

unless the feature genuinely requires them and they are strictly validated.

The smaller the request contract is, the easier it is to control what the endpoint can do.

Dispatcher Exposure Is Part of the Endpoint Design

A servlet can be active in AEM and still be unreachable from the public site because the request is blocked before it reaches Sling.

That is especially relevant for path-based endpoints such as:

text
/bin/myproject/newsletter/subscribe

If the frontend needs to call that endpoint through Publish, the corresponding request has to be allowed through the project's request-filtering and Dispatcher configuration.

I would not solve that by broadly allowing /bin/*.

The public surface should remain limited to the endpoint and HTTP methods the application actually needs.

This is also one reason I prefer resource-type servlets when the operation naturally belongs to a resource. A fixed /bin endpoint is still useful for global actions, but it needs a deliberate exposure decision.

CSRF Protection Belongs in the Request Design

For browser-based state-changing requests, CSRF handling cannot be treated as something to add after the servlet is finished.

The form, frontend request, AEM's CSRF protection, and the servlet need to be designed together.

I would not document the solution as simply adding:

java
<input type="hidden" name=":cq_csrf_token" value="...">

and consider the endpoint protected.

How the token is obtained and sent depends on how the frontend is implemented and how the endpoint is exposed.

The important point for the servlet design is that a state-changing browser request needs the appropriate CSRF protection before the operation is allowed to execute.

Repository Identity Is a Separate Decision

The newsletter example may not need repository writes at all.

The service might only call an external CRM.

But if the operation does need repository access, I would decide explicitly which identity should perform that work.

Using:

java
request.getResourceResolver()

means repository access follows the security context associated with the incoming request.

A backend operation that needs its own controlled repository permissions may instead use a dedicated service identity with access limited to the required paths and operations.

Neither choice replaces endpoint authorization.

A service user answers:

Under which repository identity should this backend operation run?

It does not answer:

Who is allowed to call this HTTP endpoint?

Those are separate boundaries.

Do Not Expose Internal Failures to the Caller

The servlet also needs to control what it returns when something fails.

I would log the detailed exception:

java
LOG.error("Newsletter subscription failed", e);

but return a controlled response to the client:

java
{
  "success": false,
  "message": "Subscription failed"
}

The caller does not need stack traces, downstream URLs, repository paths, credentials, or raw exception messages.

The server logs can contain the information required for troubleshooting.

Author Testing Is Not the Final Test

Before considering the endpoint ready, I would test it through the same route the real client will use.

For a public form, that means testing through the published site rather than calling the servlet directly on Author.

That catches a different class of problems:

  • Request blocked before reaching AEM
  • Wrong HTTP method allowed
  • Authentication or authorization behaving differently
  • CSRF handling failing
  • Dispatcher or CDN behavior affecting the request
  • Unexpected response headers or content type
  • Backend permissions differing from local or Author testing

At that point I am no longer testing only whether doPost() works.

I am testing whether the complete HTTP boundary works the way the production application expects.

Servlet or Sling Model? The Decision I Use

The comparison between a servlet and a Sling Model comes up often because both can make data available to the frontend.

I do not decide between them based on whether the output is HTML or JSON.

I look at what starts the work.

If the work starts because AEM is rendering a component, I normally start with a Sling Model.

If the work starts because a client sends an independent HTTP request to AEM, a servlet becomes a better fit.

Consider a product card component.

The component needs authored properties such as:

text
title
description
image
ctaLabel
ctaLink

That data belongs to the component.

A Sling Model can prepare it while the component is being rendered:

java
@Model(
    adaptables = SlingHttpServletRequest.class,
    resourceType = "myproject/components/productcard",
    defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL
)
public class ProductCardModel {

    @ValueMapValue
    private String title;

    @ValueMapValue
    private String description;

    public String getTitle() {
        return title;
    }

    public String getDescription() {
        return description;
    }
}

There is no reason to create:

java
GET /bin/myproject/product-card

just to retrieve values already available during component rendering. Now compare that with the store locator example.

The page renders first. Later, the visitor enters:

text
Postal code: 10001
Radius: 25 miles

The frontend now needs to send a new request and receive results based on that input.

That is no longer just component rendering.

A servlet can represent that request boundary:

GET /content/myproject/us/en/store-locator.stores.json?postalCode=10001

The Store Locator component can still have a Sling Model for its authored configuration.

The servlet handles the later search request.

Both can exist in the same feature because they solve different parts of the feature.

Servlet or Sling Model Exporter?

JSON alone is not enough reason to create a servlet.

If the requirement is:

Expose the current component model as JSON.

I would first check whether a Sling Model Exporter already fits.

For example:

java
/content/myproject/us/en/home/jcr:content/root/productcard.model.json

can represent the component model without introducing a separate custom endpoint for the same data.

A custom servlet becomes more useful when the request has behavior beyond exporting the current resource model.

For example:

java
GET /content/myproject/us/en/store-locator.stores.json?postalCode=10001

has request-specific input and processing.

Similarly:

java
POST /bin/myproject/newsletter/subscribe

represents an operation.

And:

java
POST /bin/myproject/product-updates

represents an external integration request.

Those are different from simply asking AEM for the JSON representation of an existing component.

Servlet or OSGi Service?

This is not really an either-or decision.

A servlet and an OSGi service normally sit at different layers.

For the newsletter example:

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

    String email = request.getParameter("email");

    if (email == null || email.isBlank()) {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        return;
    }

    newsletterService.subscribe(email.trim());

    response.setStatus(HttpServletResponse.SC_OK);
}

The servlet handles the request.

The service handles the subscription:

java
public interface NewsletterService {

    void subscribe(String email)
            throws NewsletterServiceException;
}

If the same subscription logic is later triggered from another Java service, workflow, job, or other backend process, that code can call NewsletterService directly.

It should not need to make an HTTP request back into its own application just to reuse the logic.

The same separation applies to repository operations.

A servlet may receive:

java
POST /bin/myproject/cleanup

but the repository cleanup itself can live behind:

java
public interface RepositoryCleanupService {

    CleanupResult cleanup(String directoryPath);
}

The servlet answers the HTTP question.

The service answers the application question.

The Boundary I Try to Keep

When reviewing a requirement, I normally separate it into three questions.

Does this belong to a resource or component being rendered?

Use a Sling Model.

Does a client need an independent HTTP interaction with AEM?

A servlet may be the right entry point.

Is there reusable business, integration, or repository logic behind that request?

Move that work into an OSGi service.

A single feature can use all three.

For example, the newsletter feature could have:

text
Newsletter component
    Sling Model
    Authored component data

Newsletter submission endpoint
    Sling Servlet
    HTTP request and response handling

Newsletter integration
    OSGi Service
    Subscription and external-system logic

Where the Cleanup Scenario Fits

The cleanup servlet is a good example of the same boundary in a different type of requirement.

In that case, the requirement was not related to component rendering. We needed an operation that could be triggered against selected DAM and Experience Fragment locations to identify and remove unwanted nodes.

A servlet made sense as the HTTP entry point because the cleanup had to be triggered explicitly.

The part I would design differently today is what happens after the request reaches the servlet.

The original implementation handled the repository operation directly:

java
boolean hasDeleted =
        deleteNodes(resourceResolver, directoryResource);

if (hasDeleted) {
    resourceResolver.commit();
}

For a maintained implementation, I would keep the servlet responsible for validating the request and triggering the operation, while moving the repository cleanup behind a service:

java
CleanupResult result =
        repositoryCleanupService.cleanup(directoryPath);

That keeps the same boundary we used in the earlier examples:

HTTP request → Servlet → Service

The servlet exists because something needs to call AEM over HTTP.

The service exists because there is actual repository work behind that request.

The complete cleanup scenario, including the property-based deletion condition, production backup, authoring freeze, execution, verification, and testing, is covered separately in Cleaning Up Unwanted DAM and Experience Fragment Nodes.

What I Took Away From These Implementations

I do not start with a servlet just because a requirement needs Java or because the frontend needs some data.

I use a servlet when the requirement needs an HTTP boundary that is separate from normal component rendering.

If the requirement is part of rendering a component, I start with a Sling Model.

If an existing component model only needs a JSON representation, I check whether Sling Model Exporter already fits.

If the request triggers business, integration, or repository work, I keep that work behind a service and use the servlet only as the entry point.

That is the boundary I try to keep in AEM projects.

A servlet should handle the request.

It should not become the entire feature.

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.