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:
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:
@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:
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:
<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:
@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:
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:
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:
POST /bin/myproject/product-updates
with a payload such as:
{
"productId": "P10045",
"eventType": "PRODUCT_UPDATED"
}
A servlet can receive that request:
@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():
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:
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:
{
"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:
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:
@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:
<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:
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:
@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:
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:
@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:
@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:
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:
POST /bin/myproject/newsletter/subscribe
The servlet should handle the HTTP concerns and delegate the actual subscription operation.
@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:
public interface NewsletterService {
void subscribe(String email)
throws NewsletterServiceException;
}
Its implementation can own the actual integration:
@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:
String email = request.getParameter("email");
if (email == null || email.isBlank()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
This does not:
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:
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:
email=user@example.com
It should not accept caller-controlled values such as:
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:
/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:
<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:
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:
LOG.error("Newsletter subscription failed", e);
but return a controlled response to the client:
{
"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:
title
description
image
ctaLabel
ctaLink
That data belongs to the component.
A Sling Model can prepare it while the component is being rendered:
@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:
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:
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:
/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:
GET /content/myproject/us/en/store-locator.stores.json?postalCode=10001
has request-specific input and processing.
Similarly:
POST /bin/myproject/newsletter/subscribe
represents an operation.
And:
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:
@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:
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:
POST /bin/myproject/cleanup
but the repository cleanup itself can live behind:
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:
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:
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:
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.