For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/advanced/java-fundamentals-for-aem-developers.md.

Java Fundamentals for AEM Developers

30 – Java Fundamentals for AEM Developers

Target: 3–4 years experienced AEM Developer Covers from your additional list: Java fundamentals — the language features that show up constantly in AEM code, built up properly for a developer whose Java is still a work in progress. Project domain: a global energy technology company's marketing site.


Before we start — why this file exists, and what it deliberately isn't

File 27 already built JUnit and Mockito up from first principles, because "how do you test this" is a constant AEM interview question and it needed its own foundation. This file does the same thing for a different gap: the Java language features that show up inside the code being tested — collections, generics, exceptions, equals / hashCode, streams, and the concurrency rules that make an OSGi service safe or dangerous — without which a Sling Model or a service class is something you can copy-paste but not confidently modify or defend under a follow-up question.

This is not a general Java course. It's the specific slice of Java that keeps appearing across files 02 through 27 of this repository, explained once, properly, with the AEM context that makes it matter rather than abstract syntax rules. If a concept here feels familiar from an earlier file, that's intentional — this is where it gets the depth those files didn't have room for.

Why this is worth taking seriously for your specific transition. Support work rarely requires writing new Java — it requires reading logs, restarting things, and occasionally patching a config. Development work requires writing Java that another developer, and an interviewer, will read and question. The gap between "I can follow this code" and "I can defend a design decision in this code" is almost entirely the gap this file is trying to close.


1. Introduction

1.1 The shape of Java in a typical AEM class

Nearly every piece of AEM Java you'll write fits one of a small number of shapes: a Sling Model interface plus implementation, an OSGi service with a @Reference or two, a servlet handling a request, or a small utility class. All four shapes lean on the same underlying Java features repeatedly — collections to hold multifield values, generics to make an adapter or a service reusable, exceptions to signal what went wrong, and (for OSGi services specifically) an understanding of thread safety, because a service is typically a singleton shared across every concurrent request.

1.2 A real project example

Requirement. A Sling Model needed to expose a list of related products for a product detail page, deduplicated by product code, sorted by relevance score, with a sensible default when the multifield was empty.

What made it a genuine Java exercise, not just an AEM one. The naive version used a List<Product> and manually looped to deduplicate — nested loops, an ArrayList.contains() check inside the outer loop, and a bug where two Product objects with the same code but built by different code paths weren't recognised as equal because equals() had never been overridden.

The fix. Overriding equals() and hashCode() consistently based on product code, switching the intermediate collection to a LinkedHashSet<Product> for order-preserving deduplication, and expressing the sort and null-handling with a stream pipeline instead of manual loops.

Why this is worth remembering as a story, not just a technique. The bug wasn't in any AEM API — it was a textbook Java mistake (missing equals() / hashCode()) that happened to surface through an AEM component. That's the pattern this whole file exists to prepare you for: an interviewer asking "why doesn't this work" and the honest answer being pure Java, not Sling.


2. Core Concepts

2.1 Collections — which one, and why it matters in a Sling Model

  • List — ordered, allows duplicates. The right choice when order matters and duplicates are meaningful — a multifield's raw contents, in authoring order.
  • Set — no duplicates, and LinkedHashSet specifically keeps insertion order while still deduplicating, which is exactly what the product-list story above needed.
  • Map — key-value lookup. AEM's ValueMap and ResourceResolver 's adaptables both lean on map-like structures, and a Map<String, Object> is the shape you'll build constantly for passing structured data (auth info to getServiceResourceResolver, for instance — file 13).

Why the choice matters beyond style: picking List when you actually need deduplication means writing manual dedup logic (and the bug the story above hit); picking HashSet when you need order means a page's product list silently re-orders itself between renders, because HashSet makes no order guarantee at all.

2.2 Generics — what they buy you, concretely

A method like resource.adaptTo(ProductModel.class) only compiles the way it does because adaptTo is generic — <T> T adaptTo(Class<T> type) — letting the same method return a ProductModel, a Resource, or any other type based purely on the Class argument, with the compiler enforcing the return type at the call site instead of you casting by hand. Without generics, every adaptation would return Object and need an explicit, unchecked cast — exactly the kind of thing that fails at runtime instead of compile time.

The concept worth being able to state plainly: generics move a class of error from runtime (a ClassCastException discovered when a page renders) to compile time (a type mismatch the IDE catches before you even save the file).

2.3 Exceptions — checked vs. unchecked, and why AEM APIs are full of both

  • Checked exceptions (LoginException, RepositoryException) must be declared or caught — the compiler forces you to acknowledge them. AEM uses these for failures that are expected to happen sometimes under normal operation (a service user's configuration is wrong, a repository operation genuinely fails) and that calling code should be forced to think about.
  • Unchecked exceptions (RuntimeException and its subclasses, like NullPointerException) don't need to be declared. They typically represent a programming error rather than an expected failure mode.

The AEM-specific habit this explains: resourceResolverFactory.getServiceResourceResolver(...) throws a checked LoginException precisely because a service user misconfiguration is a real, expected-to-sometimes-happen failure — not a bug — and the API design forces every caller to decide how to handle it rather than let it propagate as a surprise.

2.4 Try-with-resources — the mechanism behind file 20 and file 28's leak warnings

Both file 20 (JCR sessions) and file 28 (Sonar findings) treat an unclosed ResourceResolver as a serious, recurring bug. The Java mechanism that fixes it: any class implementing AutoCloseable (which ResourceResolver does) can be opened in a try (...) statement, and Java guarantees close() runs when the block exits — on every path, including an exception — without a finally block written by hand.

// The exception-safe pattern used throughout this repository from file 05 onward.
try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(authInfo)) {
    return process(resolver);
} // close() is guaranteed here, whether process() returned normally or threw

2.5 equals() and hashCode() — the contract, and why breaking it is silent

Java's contract: if two objects are equals(), they must return the same hashCode(). Breaking this doesn't throw an error — it silently corrupts anything backed by a hash structure (HashSet, HashMap), because an object can be "equal" to another by your own logic while landing in a different hash bucket, meaning a Set never recognises them as duplicates and a Map lookup never finds an existing key.

Why this is the exact bug in this file's opening story: two Product instances representing the same product, built by different code paths, were logically the same product but were never recognised as such — because Java's default equals() (inherited from Object) compares object identity, not the fields you actually care about, unless you override it.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Product)) return false;
    return Objects.equals(productCode, ((Product) o).productCode);
}

@Override
public int hashCode() {
    return Objects.hash(productCode);
}

2.6 Streams and lambdas — expressing "what," not "how"

A stream pipeline describes the result you want, letting the JVM handle the mechanics of the loop:

// The opening story's fix, expressed as a pipeline: dedupe by product code,
// sort by relevance, and fall back to an empty list rather than null.
List<Product> related = Optional.ofNullable(rawRelatedProducts)
    .orElse(Collections.emptyList())
    .stream()
    .collect(Collectors.toMap(Product::getCode, p -> p, (first, second) -> first, LinkedHashMap::new))
    .values()
    .stream()
    .sorted(Comparator.comparingDouble(Product::getRelevanceScore).reversed())
    .collect(Collectors.toList());

The habit worth internalising, not just the syntax: a stream pipeline that reads like a sentence ("take these, keep the first by code, sort by relevance") is doing exactly what the nested-loop version did, but the intent is visible in the code itself rather than buried in loop-index bookkeeping — which is also why it's easier for a reviewer (or an interviewer reading your code live) to verify it's correct.

2.7 Optional — for return values, not for every variable

Optional<T> exists to make "this might not have a value" visible in a method's signature, forcing a caller to handle the missing case explicitly rather than risk a NullPointerException three calls later. The AEM-specific discipline: use it for a method's return type when absence is a normal outcome (Optional<String> findConfiguredEndpoint()), but don't scatter Optional fields or method parameters through a Sling Model — that's a well-known Java anti-pattern, and AEM's @ValueMapValue injection already has its own, simpler way to express "this property might be absent" (a default value, or Optional<String> specifically as an injectable field type, which the Sling Models framework supports directly).

2.8 Static vs. instance state — the concept that makes or breaks a thread-safe OSGi service

An OSGi component (file 06) is typically instantiated once and shared across every concurrent request the instance handles. That single fact changes how you're allowed to write it:

  • An instance field that's mutated per-request (private List<String> results; set inside a method and read later) is shared, mutable state across every thread calling that service concurrently — a race condition waiting to happen, and one of the most common real bugs in AEM services written by developers used to request-scoped web frameworks where "one instance per request" is often the default assumption.
  • A local variable inside a method, by contrast, is stack-allocated per call and inherently thread-safe — nothing to synchronise, because nothing is shared.

The rule worth stating outright: an OSGi service should be effectively stateless — instance fields should hold only immutable configuration (set once via @Activate, never mutated afterward), and any data specific to one request should live in local variables or be passed as parameters, never stored on the service instance.

2.9 A minimal, honest concurrency vocabulary

You don't need to be a concurrency expert for a 3-4 year AEM interview, but three terms come up and are worth being precise about:

  • synchronized — a block or method that only one thread can execute at a time on a given object. A last resort for genuinely shared mutable state, and a performance cost if overused.
  • volatile — guarantees a field's writes are visible to other threads immediately, without providing mutual exclusion. Used for simple flags, not compound operations.
  • ConcurrentHashMap — a thread-safe map without needing to synchronized every access yourself, useful for the rare legitimate case of a service caching something across requests.

The honest framing for an interview: the best answer to "how do you make this OSGi service thread-safe" is usually "avoid mutable shared state entirely" — reaching for synchronized is what you do when statelessness genuinely isn't possible, not a first resort.

2.10 Interfaces vs. abstract classes — why Sling Models use interfaces the way they do

File 05 and file 23 already established the pattern: a Sling Model interface with an Impl implementation, or a Sling Model exported via ComponentExporter. The Java reasoning underneath: an interface defines a contract with no shared implementation, letting the HTL template (or a headless JSON exporter) depend only on the interface's method signatures — never on how the data is actually fetched — which is the same dependency-inversion idea behind testing a Sling Model against AemContext rather than a real repository (file 27). An abstract class is the right tool instead when several related classes genuinely share common implementation, not just a common contract — less common in typical AEM component code, more common in a shared base class for several similar OSGi services.


3. Internal Working

3.1 Why string concatenation in a loop is a real, measurable problem

String is immutable — every + concatenation creates a new String object rather than modifying one in place. Inside a loop, that means allocating and discarding a new string on every iteration:

// Quietly O(n²): each += allocates a new String and copies everything so far.
String html = "";
for (Product p : products) {
    html += "<li>" + p.getName() + "</li>";
}

// StringBuilder mutates an internal buffer — genuinely O(n).
StringBuilder html = new StringBuilder();
for (Product p : products) {
    html.append("<li>").append(p.getName()).append("</li>");
}

This rarely matters for three items in a dropdown; it matters for the kind of loop that renders every product on a large listing page — exactly file 02's Load More component territory — where the naive version's cost grows quietly worse as the page grows.

3.2 Why a checked exception on a factory method forces a design decision

getServiceResourceResolver(...) throwing LoginException as a checked exception means the compiler won't let a caller ignore the possibility. That forces one of a small number of honest choices at every call site: handle it and degrade gracefully (return an empty result, log and move on), or declare it and let a caller further up decide — but never silently swallow it without a decision being made, because the compiler won't compile code that just ignores a checked exception.

3.3 How autoboxing quietly causes a subtle bug

Integer and int aren't quite interchangeable — comparing two boxed Integer objects with == compares references, not values, and it only appears to work for small values because the Java language specification requires autoboxing to reuse cached Integer objects from -128 to 127. Larger values, or objects created with new, are separate objects, so never rely on == for boxed numbers:

Integer a = 200;
Integer b = 200;
a == b;          // false — not guaranteed to be the same cached object
a.equals(b);     // true — always correct for comparing values

The rule: always use .equals() (or unbox to a primitive int first) to compare boxed numeric types — never ==.


8. Coding Examples

8.1 Deduplicating while preserving order — the opening story's actual fix

public List<Product> getRelatedProducts() {
    if (rawRelatedProducts == null) {
        return Collections.emptyList();
    }
    // LinkedHashMap keyed by product code: keeps the FIRST occurrence
    // of each code, in the order encountered — exactly "dedupe, keep order."
    Map<String, Product> deduped = new LinkedHashMap<>();
    for (Product product : rawRelatedProducts) {
        deduped.putIfAbsent(product.getCode(), product);
    }
    List<Product> result = new ArrayList<>(deduped.values());
    result.sort(Comparator.comparingDouble(Product::getRelevanceScore).reversed());
    return result;
}

8.2 A stateless OSGi service versus the leaking version from Story 2

// WRONG: currentContext is shared, mutable state on a singleton component —
// a race condition the moment two threads call generatePreview() concurrently.
@Component(service = PreviewService.class)
public class PreviewServiceImpl implements PreviewService {
    private PageContext currentContext; // <-- the bug

    @Override
    public String generatePreview(Resource pageResource) {
        currentContext = buildContext(pageResource);
        return render(currentContext);
    }
}

// FIXED: no shared state at all — currentContext is a local variable,
// so each concurrent call gets its own, safely.
@Component(service = PreviewService.class)
public class PreviewServiceImpl implements PreviewService {
    @Override
    public String generatePreview(Resource pageResource) {
        PageContext currentContext = buildContext(pageResource);
        return render(currentContext);
    }
}

8.3 A readable stream pipeline, split from Story 3's unreadable version

// Before: correct, but everything happens in one unreadable chain.
List<String> result = products.stream()
    .filter(p -> p.isActive() && p.getStock() > 0)
    .peek(p -> log.debug("Considering {}", p.getCode()))
    .collect(Collectors.groupingBy(Product::getCategory))
    .values().stream().flatMap(List::stream)
    .map(Product::getName).collect(Collectors.toList());

// After: named intermediate steps, no side effects hidden inside the stream.
List<Product> availableProducts = products.stream()
    .filter(p -> p.isActive() && p.getStock() > 0)
    .collect(Collectors.toList());

log.debug("Considering {} available products", availableProducts.size());

Map<String, List<Product>> byCategory = availableProducts.stream()
    .collect(Collectors.groupingBy(Product::getCategory));

List<String> result = byCategory.values().stream()
    .flatMap(List::stream)
    .map(Product::getName)
    .collect(Collectors.toList());

9. Common Mistakes

MistakeWhy it happensThe actual cost
Not overriding equals() / hashCode() on a class used as a Set / Map keyDefault identity comparison "usually" seems to work in small testsSilent deduplication failures with real data from multiple sources
Storing per-request data in an OSGi service's instance fieldHabit from request-scoped frameworksA race condition invisible until real concurrent traffic
String concatenation in a loop over many itemsReads simply, works fine at small scaleQuietly quadratic cost on a large listing
Comparing boxed Integer / Long with ==Small test values happen to be cached and compare "correctly" by accidentA bug that only appears with larger real values
Reaching for synchronized as a first resortFeels like the "safe" answer to a thread-safety questionUnnecessary contention where a stateless redesign would have been simpler and faster
Scattering Optional through fields and parametersRead about it as a "modern Java" pattern without the caveatVerbose, unidiomatic code — Optional is for return values, not everywhere
An unreadable one-line stream pipeline"It's more concise" without checking it's still clearA future reader (including you, under pressure) misreads or fears touching it
Swallowing a checked exception with an empty catch blockMade the compiler stop complaining, fastest path to a green buildA real failure disappears silently instead of being handled or logged

10. Best Practices

  • Override equals() and hashCode() together, consistently, on any class used as a Set / Map key or compared for logical equality — never leave it to default identity comparison.
  • Treat an OSGi service as stateless by default. Instance fields hold only immutable configuration set once; per-request data lives in local variables or parameters.
  • Use StringBuilder for concatenation inside a loop, and plain + for a handful of one-off concatenations where readability wins and the cost is irrelevant.
  • Compare boxed numeric types with .equals(), never ==.
  • Use Optional as a return type for genuinely absent values — not as a field or parameter type scattered through a class.
  • Name intermediate steps in a stream pipeline once it stops being obviously readable in one chain — a pipeline should be easier to read than the loop it replaced, not harder.
  • Never swallow a checked exception silently — handle it with an explicit, logged decision, or declare it and let a caller decide.
  • Reach for synchronized / volatile /concurrent collections only after confirming statelessness genuinely isn't possible — not as a first response to a thread-safety question.

11. Debugging Tips

SymptomWhere to lookWhat it usually means
A Set or Map "loses" entries that look identicalWhether equals() / hashCode() are overridden on the key/element classDefault identity comparison silently failing to recognise duplicates
Data from one request appears to leak into anotherAny mutable instance field on an OSGi serviceShared singleton state under concurrent access
A listing page gets noticeably slower as content growsString concatenation inside the render loopQuadratic cost from repeated String allocation
A numeric comparison works in tests but fails with real dataWhether boxed types are compared with ==The Integer cache masking the bug for small values
A stream pipeline "does something wrong" but the logic looks right at a glanceWhether operations are ordered/chained in a way that's easy to misreadSplit it into named steps to verify each stage independently

12. Performance Notes

  • StringBuilder versus repeated += concatenation matters proportionally to loop size — negligible for a handful of items, real for a large listing page (file 02's territory).
  • Stream pipelines are not inherently faster than an equivalent loop, and sometimes slightly slower due to overhead — the reason to prefer them is readability and correctness of intent, not raw speed. Don't claim a performance win in an interview that isn't the actual reason you'd choose one.
  • Overusing synchronized on a hot path (a servlet or Sling Model method called on every request) can become a real bottleneck under load — which is exactly why statelessness is the preferred fix, avoiding the need for synchronisation entirely rather than making synchronisation fast.

15. Comparison Tables

ListSetMap
DuplicatesAllowedNot allowedKeys unique, values can repeat
OrderInsertion order (implementation-dependent for some types)No guarantee, except LinkedHashSetNo guarantee, except LinkedHashMap
Typical AEM useA multifield's raw valuesDeduplicating by identity/business keyStructured data, auth info maps
Checked exceptionUnchecked exception
Must be declared/caughtYesNo
RepresentsAn expected, sometimes-happens failureUsually a programming error
AEM exampleLoginException, RepositoryExceptionNullPointerException, IllegalArgumentException
Instance fieldLocal variable
Shared across concurrent calls on a singleton service?YesNo
Safe for per-request data on an OSGi service?NoYes
Appropriate useImmutable config set once at @ActivateAnything specific to one method call
synchronizedvolatileStateless design
SolvesMutual exclusion for compound operationsVisibility of a single field's writesRemoves the need for either
CostContention under loadNone, but limited guaranteeNone — the preferred default

Next topic

System design basics for AEM developers — how to approach an open-ended "design a system" question when the system is a multi-site, headless-capable AEM platform: scalability, caching layers, content modelling for reuse, and the trade-offs an AEM-specific system design answer is expected to name.


Topic 30 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.