# Putting It Together: A Worked Backend Example

The earlier posts each covered one backend building block. Real features use several at once. In this final post we build one small but complete feature on **AEM as a Cloud Service**, and point out where each earlier lesson applies. The feature is a **page title audit**: authors trigger a scan of a content tree, and it records which pages are missing a title.

## The Feature

- An author sends a request to start an audit
- The audit runs **in the background**, so the request returns immediately
- It scans pages under a configured root path and stores a **report** in the repository
- Authors can read the latest report
- It exists **only on Author**, and each environment can configure the root path and limits

## The Design

| Need | Building block | From |
| --- | --- | --- |
| Settings per environment | OSGi configuration with run modes | Essentials, Part 3 |
| Read content, write the report | A service user with narrow permissions | Essentials, Part 4 |
| The scanning logic | An OSGi service | Essentials, Parts 1 and 2 |
| Run in the background, once, with retries | A Sling Job and a job consumer | Advanced, Part 3 |
| Trigger and read it over HTTP | A servlet registered by resource type | Essentials, Part 5 |
| Keep it off Publish | `configurationPolicy = REQUIRE` and an Author-only config | Advanced, Part 6 |
| Confidence | Unit tests and good logging | Essentials, Part 6 |
| A build that passes | Closed resolvers, no scheduler, no path servlets | Advanced, Part 7 |

The flow:

```plaintext
Author  --POST-->  AuditServlet  --addJob-->  Sling Job queue
                                                    |
                                          PageAuditJobConsumer
                                                    |
                                            PageAuditService
                                       (service user: auditor)
                                                    |
                                       writes /var/myproject/audit/report-...
Author  --GET--->  AuditServlet  reads the latest report (caller's permissions)
```

## Step 1: Repository Setup with Repoinit

The service user, its permissions and the report folder are all created from code.

`org.apache.sling.jcr.repoinit.RepositoryInitializer~myproject-audit.cfg.json`

```json
{
  "scripts": [
    "create path /var/myproject/audit(sling:Folder)",
    "create service user myproject-auditor with path system/myproject",
    "set ACL for myproject-auditor",
    "  allow jcr:read on /content/myproject",
    "  allow jcr:read,jcr:addChildNodes,jcr:modifyProperties on /var/myproject/audit",
    "end",
    "set ACL for content-authors",
    "  allow jcr:read on /var/myproject/audit",
    "end"
  ]
}
```

The auditor can **read** the content and **write only** under the audit folder. Authors can **read** the reports. `content-authors` is a placeholder for the group your project uses, so replace it with your real group name.

`org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended~myproject-audit.cfg.json`

```json
{ "user.mapping": [ "myproject.core:auditor=[myproject-auditor]" ] }
```

## Step 2: The Service

```java
@Component(service = PageAuditService.class)
@Designate(ocd = PageAuditServiceImpl.Config.class)
public class PageAuditServiceImpl implements PageAuditService {

    private static final Logger LOG = LoggerFactory.getLogger(PageAuditServiceImpl.class);
    private static final String REPORT_FOLDER = "/var/myproject/audit";

    @ObjectClassDefinition(name = "My Project - Page Audit")
    public @interface Config {
        @AttributeDefinition(name = "Root path")
        String rootPath() default "/content/myproject";

        @AttributeDefinition(name = "Maximum pages to scan")
        int maxPages() default 500;
    }

    @Reference
    private ResourceResolverFactory resolverFactory;

    private volatile Config config;

    @Activate
    @Modified
    protected void activate(Config config) {
        this.config = config;
    }

    @Override
    public String runAudit() {
        Map<String, Object> auth =
                Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "auditor");

        try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(auth)) {
            Resource root = resolver.getResource(config.rootPath());
            Resource folder = resolver.getResource(REPORT_FOLDER);
            if (root == null || folder == null) {
                LOG.warn("Audit skipped: root {} or folder {} not found", config.rootPath(), REPORT_FOLDER);
                return null;
            }

            List<String> missing = new ArrayList<>();
            int[] scanned = {0};
            scan(root, missing, scanned);

            Map<String, Object> props = new HashMap<>();
            props.put("jcr:primaryType", "nt:unstructured");
            props.put("scanned", scanned[0]);
            props.put("missingTitles", missing.toArray(new String[0]));
            props.put("created", Calendar.getInstance());

            Resource report = resolver.create(folder, "report-" + System.currentTimeMillis(), props);
            resolver.commit();

            LOG.info("Audit finished: scanned {} pages, {} without a title", scanned[0], missing.size());
            return report.getPath();
        } catch (LoginException | PersistenceException e) {
            LOG.error("Page audit failed", e);
            return null;
        }
    }

    private void scan(Resource parent, List<String> missing, int[] scanned) {
        for (Resource child : parent.getChildren()) {
            if (scanned[0] >= config.maxPages()) {
                return;
            }
            if ("cq:Page".equals(child.getResourceType())) {
                scanned[0]++;
                String title = child.getValueMap().get("jcr:content/jcr:title", String.class);
                if (title == null || title.trim().isEmpty()) {
                    missing.add(child.getPath());
                }
                scan(child, missing, scanned);
            }
        }
    }
}
```

Check the details against the earlier posts:

- The resolver is opened with **try-with-resources**, so it's always closed
- Access comes from a **service user**, with the sub-service name from the mapping
- The scan has a **cap** (`maxPages`), so one run can't grow without limit
- Failures are **logged** and return `null`, and there are **no secrets** in the logs

## Step 3: The Job Consumer

```java
@Component(
    service = JobConsumer.class,
    property = { JobConsumer.PROPERTY_TOPICS + "=myproject/audit/run" })
public class PageAuditJobConsumer implements JobConsumer {

    @Reference
    private PageAuditService auditService;

    @Override
    public JobResult process(Job job) {
        String reportPath = auditService.runAudit();
        return reportPath == null ? JobResult.FAILED : JobResult.OK;
    }
}
```

Because the platform stores the job and runs it on **one instance**, the audit won't run twice on a cluster. A failed run is **retried**. Running an audit twice is harmless, which satisfies the idempotence rule, because each run writes its own new report.

## Step 4: The Servlet, Author Only

```java
@Component(service = Servlet.class, configurationPolicy = ConfigurationPolicy.REQUIRE)
@SlingServletResourceTypes(
        resourceTypes = "myproject/components/page",
        methods = { "GET", "POST" },
        selectors = "audit",
        extensions = "json")
public class AuditServlet extends SlingAllMethodsServlet {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Reference
    private JobManager jobManager;

    @Override
    protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws IOException {
        jobManager.addJob("myproject/audit/run", Collections.emptyMap());
        response.setStatus(SlingHttpServletResponse.SC_ACCEPTED);
        response.setContentType("application/json");
        response.getWriter().write("{\"status\":\"queued\"}");
    }

    @Override
    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws IOException {
        Resource folder = request.getResourceResolver().getResource("/var/myproject/audit");
        Resource latest = null;
        if (folder != null) {
            for (Resource child : folder.getChildren()) {
                latest = child;   // children are in creation order, so the last is the latest
            }
        }
        if (latest == null) {
            response.sendError(SlingHttpServletResponse.SC_NOT_FOUND, "No report yet");
            return;
        }
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("report", latest.getPath());
        body.put("scanned", latest.getValueMap().get("scanned", 0));
        body.put("missingTitles", latest.getValueMap().get("missingTitles", new String[0]));
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        MAPPER.writeValue(response.getWriter(), body);
    }
}
```

Notice the security design:

- **Resource type binding**, not a path, so normal permissions and Dispatcher filters apply
- **GET uses the caller's resolver.** Someone without read access to `/var/myproject/audit` just sees a 404
- `configurationPolicy = REQUIRE` means the servlet exists only where a configuration exists. The next step supplies one for Author only

(This example assumes the folder's children are in creation order, which is true for ordered folders but not guaranteed everywhere. In a real project, sort by the `created` property.)

## Step 5: Configuration by Run Mode

```plaintext
ui.config/.../osgiconfig/
├── config/
│   ├── com.myproject.core.services.PageAuditServiceImpl.cfg.json
│   ├── org.apache.sling.jcr.repoinit.RepositoryInitializer~myproject-audit.cfg.json
│   └── org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended~myproject-audit.cfg.json
├── config.author/
│   └── com.myproject.core.servlets.AuditServlet.cfg.json      ← { }  (enables the servlet)
└── config.dev/
    └── com.myproject.core.services.PageAuditServiceImpl.cfg.json   ← smaller maxPages for Dev
```

The empty Author file `{}` is enough to satisfy the `REQUIRE` policy. There is no matching file under `config.publish`, so the servlet is not active on Publish. As a second layer, don't add a Dispatcher filter rule for the `audit` selector.

## Step 6: A Unit Test

```java
@ExtendWith(AemContextExtension.class)
class PageAuditServiceImplTest {

    private final AemContext context = new AemContext();

    @Test
    void reportsPagesWithoutTitle() throws Exception {
        context.create().resource("/var/myproject/audit", "jcr:primaryType", "sling:Folder");
        context.create().page("/content/myproject/en", "/conf/myproject/settings/wcm/templates/page", "Home");
        context.create().page("/content/myproject/en/untitled", "/conf/myproject/settings/wcm/templates/page", "");

        PageAuditService service = context.registerInjectActivateService(
                new PageAuditServiceImpl(),
                Map.of("rootPath", "/content/myproject", "maxPages", 50));

        String reportPath = service.runAudit();

        assertNotNull(reportPath);
        String[] missing = context.resourceResolver().getResource(reportPath)
                .getValueMap().get("missingTitles", String[].class);
        assertEquals(1, missing.length);
    }
}
```

AEM Mocks can't check permissions, the repoinit script or the cluster behavior, so you still verify those on a Dev environment.

## What to Check on Dev

1. The bundle is **Active** and all components are **satisfied** in the Developer Console
2. `POST` to `/content/myproject/en.audit.json` returns **202**, and the log shows the audit starting and finishing
3. `GET` returns the latest report for an author, and **404 for a user without access**
4. The servlet is **absent on Publish**
5. After a deployment, the audit still runs **once**

## What This Example Shows

Every piece is small, and each one follows a rule from earlier in this series. That's the point: Cloud-ready backend code is mostly a set of habits applied consistently.

- **Configuration, access and behavior** are all **in code**, deployed the same way everywhere
- **Background work** goes through jobs, so it runs once and can be retried
- **Access** is narrow: a service user for the work, the caller's own permissions for reading
- **Exposure** is deliberate: resource type, Author only, no Dispatcher rule

## Series Wrap-Up

That completes **AEM Backend Advanced**. Together with the Essentials series, you now have a practical foundation for AEM backend development on Cloud Service, including the differences to know when you work with 6.5 projects. From here, a good next step is to extend this example: add a scheduled run, send a notification when titles are missing, or expose the report in a component. Then write about what you learned.
