Real-World AEM Scenario — Cleaning Up Unwanted DAM and Experience Fragment Nodes
A production AEM cleanup scenario where a servlet was used to identify and remove unwanted nodes from DAM and Experience Fragment paths.
Part of the Real-World AEM Problems series. For the servlet concepts used in this implementation, see Servlets in AEM — Request Resolution, Security, and Production Architecture.
The Production Problem
We received a request from the business to clean up unwanted folders or nodes that had accumulated under DAM and Experience Fragment paths in production.
These entries were creating problems for authors because they had to move through unnecessary nodes while looking for the content they needed.
Since this involved deleting content from the production repository, we first needed to understand exactly what qualified as an unwanted node.
Before writing the cleanup logic, we checked:
- What was different about the unwanted nodes
- Which properties could be used to identify them
- Which DAM and Experience Fragment paths were affected
- Whether the cleanup could be limited to those paths
- How we could verify the nodes before deleting them
The first step was to inspect the affected nodes in the repository.
What We Found During the POC
I created a POC and checked the affected nodes in JCR.
The original requirement described them as empty folders or nodes. After inspecting them, the condition was more specific than checking whether a node had children.
The affected nodes contained properties such as:
accountIDmediaIdplayerIdvideoIdvideoType
The cleanup condition used in the POC was:
private boolean shouldDeleteNode(ValueMap properties) {
String accountID = properties.get("accountID", String.class);
String mediaId = properties.get("mediaId", String.class);
String playerId = properties.get("playerId", String.class);
String videoId = properties.get("videoId", String.class);
String videoType = properties.get("videoType", String.class);
return accountID != null
&& isEmpty(mediaId)
&& isEmpty(playerId)
&& isEmpty(videoId)
&& isEmpty(videoType);
}
In this implementation, an "empty node" did not mean every repository node without children.
A node became a cleanup candidate when accountID was present while mediaId, playerId, videoId, and videoType were empty.
This condition came from what we found during the POC. A generic empty-folder check would have been a different operation and could have removed nodes outside the actual requirement.
Why We Used a Servlet
The cleanup was not limited to one hard-coded repository location.
We needed to run the same check against different DAM and Experience Fragment paths. For the POC, we used a path-based Sling Servlet and passed the target repository location through the directoryPath request parameter.
The servlet was registered at:
/bin/deletenodes
During local testing, we could call it with a target path such as:
/bin/deletenodes?directoryPath=/content/dam
The servlet used the supplied path as the starting resource, checked its immediate child nodes against the cleanup condition, deleted matching nodes, and committed the changes.
The Original Implementation
For the POC, the cleanup was implemented as a path-based Sling Servlet registered at /bin/deletenodes.
The servlet accepted directoryPath as a request parameter, resolved that resource, checked its immediate children, and deleted the children that matched the property condition identified during the POC.
This was the original implementation:
import java.io.IOException;
import javax.servlet.Servlet;
import javax.servlet.http.HttpServletResponse;
import lombok.NonNull;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ValueMap;
import org.apache.sling.api.servlets.HttpConstants;
import org.apache.sling.api.servlets.ServletResolverConstants;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Component;
@Component(
service = Servlet.class,
property = {
Constants.SERVICE_DESCRIPTION + "=Delete Nodes Servlet",
ServletResolverConstants.SLING_SERVLET_PATHS + "=/bin/deletenodes",
ServletResolverConstants.SLING_SERVLET_METHODS + "=" + HttpConstants.METHOD_GET
}
)
public class DeletePagesServlet extends SlingSafeMethodsServlet {
@Override
protected void doGet(
SlingHttpServletRequest request,
@NonNull SlingHttpServletResponse response) throws IOException {
String directoryPath = request.getParameter("directoryPath");
if (isInvalidDirectoryPath(directoryPath, response)) {
return;
}
ResourceResolver resourceResolver = request.getResourceResolver();
Resource directoryResource = resourceResolver.getResource(directoryPath);
if (isDirectoryNotFound(directoryResource, response)) {
return;
}
try {
assert directoryResource != null;
boolean hasDeleted =
deleteNodes(resourceResolver, directoryResource);
if (hasDeleted) {
resourceResolver.commit();
}
sendSuccessResponse(response);
} catch (PersistenceException | RuntimeException e) {
sendErrorResponse(response, e);
}
}
private boolean isInvalidDirectoryPath(
String directoryPath,
SlingHttpServletResponse response) throws IOException {
if (directoryPath == null || directoryPath.isEmpty()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write(
"Directory path parameter is missing"
);
return true;
}
return false;
}
private boolean isDirectoryNotFound(
Resource directoryResource,
SlingHttpServletResponse response) throws IOException {
if (directoryResource == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.getWriter().write("Directory not found");
return true;
}
return false;
}
private boolean deleteNodes(
ResourceResolver resourceResolver,
Resource directoryResource) throws PersistenceException {
boolean hasDeleted = false;
for (Resource child : directoryResource.getChildren()) {
ValueMap properties = child.getValueMap();
if (shouldDeleteNode(properties)) {
resourceResolver.delete(child);
hasDeleted = true;
}
}
return hasDeleted;
}
private boolean shouldDeleteNode(ValueMap properties) {
String accountID =
properties.get("accountID", String.class);
String mediaId =
properties.get("mediaId", String.class);
String playerId =
properties.get("playerId", String.class);
String videoId =
properties.get("videoId", String.class);
String videoType =
properties.get("videoType", String.class);
return accountID != null
&& isEmpty(mediaId)
&& isEmpty(playerId)
&& isEmpty(videoId)
&& isEmpty(videoType);
}
private boolean isEmpty(String value) {
return value == null || value.isEmpty();
}
private void sendSuccessResponse(
SlingHttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write(
"Nodes deleted successfully"
);
}
private void sendErrorResponse(
SlingHttpServletResponse response,
Exception e) throws IOException {
response.setStatus(
HttpServletResponse.SC_INTERNAL_SERVER_ERROR
);
response.getWriter().write(
"Commit failed: " + e.getMessage()
);
}
}
This code is kept as the original POC implementation. There are a few design decisions in it that I would handle differently now, but changing them here would no longer represent what was implemented at the time.
How the Cleanup Worked
The request provided the repository location through directoryPath.
The servlet resolved that path using the ResourceResolver from the Sling request:
ResourceResolver resourceResolver = request.getResourceResolver();
Resource directoryResource = resourceResolver.getResource(directoryPath);
If directoryPath was missing, the servlet returned 400 Bad Request. If the resource could not be resolved, it returned 404 Not Found.
Once the starting resource was available, the servlet iterated over its immediate children:
for (Resource child : directoryResource.getChildren()) {
ValueMap properties = child.getValueMap();
if (shouldDeleteNode(properties)) {
resourceResolver.delete(child);
hasDeleted = true;
}
}
Each child was checked against the property condition identified during the POC.
A resource was selected for deletion only when accountID was present and mediaId, playerId, videoId, and videoType were empty.
If at least one resource was deleted, the changes were committed:
if (hasDeleted) {
resourceResolver.commit();
}
The cleanup only checked the immediate children of the supplied resource. It did not recursively scan the complete subtree.
That behavior was enough for the cleanup requirement we were testing in the POC.
What I Would Change in the Design Today
The POC solved the cleanup requirement, but I would not expose the same endpoint unchanged for a production cleanup.
There are a few parts I would change.
1. Use POST Instead of GET
The original servlet registered the cleanup operation for GET:
ServletResolverConstants.SLING_SERVLET_METHODS
+ "="
+ HttpConstants.METHOD_GET
and performed the deletion inside doGet().
That was convenient during the POC because the endpoint could be called directly from the browser:
/bin/deletenodes?directoryPath=/content/dam
But this request changes repository content.
For this type of cleanup operation, I would use POST instead:
POST /bin/myproject/cleanup
The servlet would also extend SlingAllMethodsServlet rather than SlingSafeMethodsServlet.
For example:
@Component(
service = Servlet.class,
property = {
ServletResolverConstants.SLING_SERVLET_PATHS
+ "=/bin/myproject/cleanup",
ServletResolverConstants.SLING_SERVLET_METHODS
+ "=" + HttpConstants.METHOD_POST
}
)
public class RepositoryCleanupServlet extends SlingAllMethodsServlet {
@Override
protected void doPost(
SlingHttpServletRequest request,
SlingHttpServletResponse response) throws IOException {
// Validate request and trigger cleanup
}
}
The original GET endpoint remains part of the POC implementation, but I would not use the same HTTP contract when implementing it again.
2. Restrict the Repository Paths
The original servlet checked whether directoryPath was present:
if (directoryPath == null || directoryPath.isEmpty()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return true;
}
It did not check whether the supplied path belonged to one of the locations intended for cleanup.
For a deletion endpoint, that validation needs to be stricter.
If the operation is intended only for selected DAM and Experience Fragment locations, the servlet should reject paths outside those locations.
For example:
private boolean isAllowedPath(String path) {
return path.startsWith("/content/dam/myproject/")
|| path.startsWith(
"/content/experience-fragments/myproject/"
);
}
The paths above are only examples. The actual allowed roots should match the content structure of the project.
I would preferably keep those allowed roots in configuration rather than spreading repository paths through the servlet code.
The request path should then be validated against the configured roots before any delete operation starts.
Checking only whether directoryPath is empty is not enough when that parameter controls where repository deletion can happen.
3. Keep Caller Authorization and Repository Permissions Separate
The original implementation uses the resolver from the incoming request:
ResourceResolver resourceResolver =
request.getResourceResolver();
The repository operation therefore runs with the security context associated with that request.
That can be valid when the requirement is for the authenticated caller's repository permissions to control the operation.
A controlled maintenance operation can have a different requirement. In that case, I would consider running the repository cleanup through a dedicated service identity with permissions limited to the required repository locations.
These are separate security decisions.
HTTP authorization controls who can call the cleanup endpoint.
Repository authorization controls what the cleanup operation can modify after it starts.
Using a service user does not automatically secure the servlet. The endpoint still needs to restrict who can trigger the operation.
The service user should also receive only the repository permissions required for the cleanup paths.
4. Move the Repository Cleanup Into a Service
The original servlet contains both HTTP handling and repository cleanup:
private boolean deleteNodes(
ResourceResolver resourceResolver,
Resource directoryResource) throws PersistenceException {
...
}
That was enough for the POC.
For the production version, I would keep the servlet focused on the request:
- Validate the request
- Check whether the caller can trigger the operation
- Validate the target path
- Call the cleanup logic
- Build the HTTP response
The repository operation can sit behind an OSGi service:
public interface RepositoryCleanupService {
CleanupResult cleanup(String directoryPath);
}
The servlet can then call:
CleanupResult result =
repositoryCleanupService.cleanup(directoryPath);
This keeps the repository logic out of the HTTP layer and makes the cleanup logic easier to test independently.
It also allows the same cleanup operation to be called from another controlled AEM process later without reusing servlet code.
5. Add a Dry-Run Option
The original POC deleted a resource as soon as it matched the condition:
if (shouldDeleteNode(properties)) {
resourceResolver.delete(child);
}
For a production cleanup, I would add a dry-run option.
The same property check could run without calling delete() or commit(). Instead, it could return the paths that match the cleanup condition.
For example:
/content/dam/.../node-1
/content/dam/.../node-2
/content/dam/.../node-3
We could review the target path, number of candidates, and exact resources before running the actual deletion.
The execution would then have two modes:
dryRun=true -> identify matching resources only
dryRun=false -> delete matching resources
This was not part of the original POC. It is something I would add if implementing the cleanup again.
6. Do Not Return Internal Exceptions in the Response
The original servlet returned the exception message directly:
response.getWriter().write(
"Commit failed: " + e.getMessage()
);
That was useful while testing the POC, but I would not return internal exception details from a production endpoint.
I would log the exception on the server:
LOG.error(
"Repository cleanup failed for path: {}",
directoryPath,
e
);
and return a controlled response:
Repository cleanup failed
The detailed exception belongs in the server logs. The HTTP response only needs to tell the caller that the operation failed.
Production Execution Plan
Getting the cleanup logic working locally was only part of the work. Before running it against production content, we prepared the environment and verified the affected repository locations.
1. Back Up the Production Environment
Before starting the cleanup, we made sure a production backup was available.
The operation deletes repository resources and commits those changes, so we needed a recovery option before running it against production content.
2. Freeze Content Authoring
We temporarily stopped authoring activity during the cleanup.
This kept the affected repository paths stable between the initial verification and the actual deletion.
Without an authoring freeze, content could be created, modified, or moved while the cleanup was running, which would make the before-and-after verification harder.
3. Capture the State Before Cleanup
Before executing the servlet, we captured screenshots of the affected DAM and Experience Fragment locations.
The screenshots gave us a reference for what authors were seeing before the cleanup and which unwanted entries were present.
After the cleanup, we could compare the same locations and verify that the expected nodes had been removed.
If I were documenting this implementation again, I would keep the actual before-and-after screenshots here after removing any project-specific or sensitive information.
4. Verify the Target Path
The original servlet accepted the repository location through:
directoryPath
That value controlled where the servlet started checking child resources.
Before execution, we verified that the supplied path pointed to the intended DAM or Experience Fragment location.
For example:
/content/dam/...
The POC did not enforce an allowlist in code, so checking the target path was an important part of the production execution process.
In the improved implementation, I would enforce the allowed repository roots in code as discussed earlier.
5. Execute the Cleanup
Once the backup was available, authoring was stopped, the current state was captured, and the target path was verified, we could execute the cleanup.
For every immediate child under the supplied path, the servlet checked the properties identified during the POC.
Only matching resources were deleted:
if (shouldDeleteNode(properties)) {
resourceResolver.delete(child);
hasDeleted = true;
}
If at least one resource was deleted, the servlet committed the changes:
if (hasDeleted) {
resourceResolver.commit();
}
The original servlet returned:
Nodes deleted successfully
after successful processing.
That response only confirmed that the servlet completed without returning an error. We still verified the repository after execution.
6. Verify the Result
After the cleanup completed, we checked the affected DAM and Experience Fragment locations again.
The main check was simple:
Were the unwanted nodes removed while the content that needed to remain was still available?
We compared the repository state with the screenshots captured before the cleanup and checked the affected locations from the authoring side as well.
At the repository level, we also needed to confirm that nodes matching the cleanup condition were removed and unrelated nodes remained untouched.
Once those checks were complete, the cleanup operation was finished.
Testing the Cleanup Logic
Since the servlet deletes repository resources, the deletion condition was the main part I wanted covered by tests.
The original implementation achieved more than 80% code coverage.
The tests covered cases around request validation, repository lookup, deletion, and commit failures. The important cases included:
- Missing directoryPath
- Repository path not found
- Directory with no matching children
- Resource matching the cleanup condition
- Resource not matching the cleanup condition
- Repository commit failure
The most important test is the condition that decides whether a resource is deleted.
A matching resource should have accountID and no values for mediaId, playerId, videoId, or videoType.
For example:
@Test
void testDoGet_DeletesMatchingNode() throws Exception {
when(request.getParameter("directoryPath"))
.thenReturn("/content/test");
when(request.getResourceResolver())
.thenReturn(resourceResolver);
when(resourceResolver.getResource("/content/test"))
.thenReturn(directoryResource);
when(directoryResource.getChildren())
.thenReturn(Collections.singletonList(childResource));
when(childResource.getValueMap())
.thenReturn(valueMap);
when(valueMap.get("accountID", String.class))
.thenReturn("12345");
when(valueMap.get("mediaId", String.class))
.thenReturn(null);
when(valueMap.get("playerId", String.class))
.thenReturn(null);
when(valueMap.get("videoId", String.class))
.thenReturn(null);
when(valueMap.get("videoType", String.class))
.thenReturn(null);
deletePagesServlet.doGet(request, response);
verify(resourceResolver).delete(childResource);
verify(resourceResolver).commit();
verify(response)
.setStatus(HttpServletResponse.SC_OK);
}
The opposite case is just as important.
If one of the properties that should be empty contains a value, that resource must not be deleted.
For example, if videoId contains a value:
when(valueMap.get("accountID", String.class))
.thenReturn("12345");
when(valueMap.get("videoId", String.class))
.thenReturn("video-123");
the test should verify:
verify(resourceResolver, never())
.delete(any(Resource.class));
verify(resourceResolver, never())
.commit();
For this cleanup, I would pay more attention to these positive and negative deletion cases than to the coverage percentage alone.
The tests need to prove both sides of the condition:
- A cleanup candidate is deleted.
- A valid resource is left untouched.
If I implemented the improved version with allowed repository roots and a dry-run option, I would add tests for those two behaviors as well.
What I Took Away From This Implementation
The actual resourceResolver.delete() call was the simple part of this requirement.
Most of the work was around identifying the correct nodes, making sure the cleanup ran against the intended repository location, preparing the production environment, and verifying the content after execution.
The POC gave us a working property-based condition for identifying the unwanted nodes and a servlet that could run the same cleanup against different DAM and Experience Fragment locations.
The production execution added the controls around that code:
- Back up the environment before deletion
- Stop authoring while the repository is being changed
- Capture the affected locations before execution
- Verify the target repository path
- Run the cleanup
- Check the same locations after execution
Looking at the implementation now, I would keep the property-based identification logic but change how the operation is exposed and controlled.
I would use POST instead of GET, restrict the repository paths the endpoint can operate on, make caller authorization explicit, separate the cleanup logic from the servlet, and add a dry-run option before deleting anything.
I would also make a deliberate decision about repository identity. If the cleanup should run with the caller's permissions, the request ResourceResolver can remain part of the design. If it should run as a controlled backend operation, I would use a narrowly permissioned service identity instead.
The original implementation solved the cleanup requirement.
Revisiting it later shows where the boundary needs to become stronger when the same type of operation moves from a POC into a maintained production capability.
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.