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, andLinkedHashSetspecifically keeps insertion order while still deduplicating, which is exactly what the product-list story above needed.Map— key-value lookup. AEM'sValueMapandResourceResolver's adaptables both lean on map-like structures, and aMap<String, Object>is the shape you'll build constantly for passing structured data (auth info togetServiceResourceResolver, 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 (
RuntimeExceptionand its subclasses, likeNullPointerException) 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.
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.
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 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 tosynchronizedevery 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:
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:
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
8.2 A stateless OSGi service versus the leaking version from Story 2
8.3 A readable stream pipeline, split from Story 3's unreadable version
9. Common Mistakes
10. Best Practices
- Override
equals()andhashCode()together, consistently, on any class used as aSet/Mapkey 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
StringBuilderfor 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
Optionalas 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
12. Performance Notes
StringBuilderversus 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
synchronizedon 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
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.