Real-World Scenario — When a Sling Model Earns Its Place (and When ValueMap Is Enough)
Where Sling Models are genuinely the right abstraction in real AEM components, the recurring null-adaptation and over-injection problems that show up once a project has dozens of them, and honest criteria for when raw resource/ValueMap access is simpler, with a full working example.
Part of the Real-World AEM Problems series. Background reading: Understanding Sling Models in AEM covers the adaptation and injection concepts behind what's discussed here.
Sixth stop in the building-blocks pass: Sling Models. Every previous post in this pass has assumed a model exists somewhere behind the component or servlet — here's where that assumption actually earns its place, and where a model is genuinely more ceremony than the component needs.
Where It's Actually Used
Any component with real logic beyond "print this property." The moment a component needs to compute something (format a date, decide whether to show a fallback, combine two properties into one string), that logic belongs in a Sling Model rather than an HTL expression — it's the difference between logic you can unit test in isolation and logic you can only verify by rendering the whole page.
A component backing both traditional HTL rendering and a headless JSON export. The Sling Model Exporter lets the exact same model back both a server-rendered component and a .model.json endpoint for a headless frontend — this is the single strongest real reason to invest in a model rather than scattering the same logic across an HTL script and a separate servlet.
Anything adapting from more than one source. A model combining data from the resource itself, a service lookup (inventory, pricing), and the current page's properties has a natural home in a single model class with multiple injected members — trying to assemble the same combination directly in HTL with several data-sly-use calls gets unreadable fast.
Real Problems You'll Hit
Null adaptation failures that only show up for specific resources. A model adapted from a resource that doesn't actually have the expected sling:resourceType, or is missing an expected child node, can fail adaptation silently — the model doesn't inject, data-sly-use skips the block, and the component just renders as if it were empty, with no error anywhere obvious. This tends to surface only for edge-case content (a page authored slightly differently than the "normal" ones) and takes real time to trace back to "the model never adapted" rather than "the model has a bug."
A model that's become a dumping ground. A model that started with three @ValueMapValue fields accumulates injected services, child resources, and computed getters over a year of feature additions until it's doing the job of three or four separate concerns — at that point unit testing even one getter means constructing mocks for everything else the class happens to also do, which is the same "too many responsibilities" problem services run into, just showing up in models instead.
@Inject field ambiguity when multiple injectors could apply. A field annotated with the generic @Inject (rather than a specific annotation like @ValueMapValue or @ChildResource) can resolve from an unexpected injector depending on annotation processing order, producing a value that's technically correct in testing but subtly wrong in a slightly different content structure. Being explicit about which injector to use avoids this entirely, and it's the reason most real projects standardize on the specific annotations rather than the generic one.
When You Actually Need It vs When It's Overkill
Build a model when: the component needs real logic (computation, combining multiple sources, fallback behavior), or the same data needs to back both HTL rendering and a headless JSON export.
Skip it when: the component is genuinely a straight pass-through of one or two properties with no logic — ${properties.title} directly in HTL via the resource's ValueMap is simpler, has nothing to adapt or fail, and is one less class to maintain for logic that will never exist.
A genuinely common overkill pattern: building a full @Model class for a component that renders exactly one static heading and one static paragraph field, with zero computation or combination of sources. That's a model earning its keep on complexity it doesn't have — direct ValueMap access in HTL does the identical job with nothing to adapt.
Concrete Example
A model combining resource data with a service lookup — the genuine multi-source case that justifies a model over direct ValueMap access:
@Model(adaptables = Resource.class,
defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class ProductSummaryModel {
@ValueMapValue
private String sku;
@ValueMapValue
@Default(values = "Product")
private String fallbackTitle;
@OSGiService
private PricingLookupService pricingLookupService;
@SlingObject
private Resource resource;
private Optional<PriceInfo> priceInfo;
@PostConstruct
protected void init() {
priceInfo = sku != null
? pricingLookupService.getPrice(sku)
: Optional.empty();
}
public String getTitle() {
ValueMap valueMap = resource.getValueMap();
return valueMap.get("jcr:title", fallbackTitle);
}
public String getFormattedPrice() {
return priceInfo.map(PriceInfo::getFormattedAmount).orElse("Price unavailable");
}
public boolean isPriceAvailable() {
return priceInfo.isPresent();
}
}
<div class="product-summary" data-sly-use.model="com.example.core.models.ProductSummaryModel">
<h3>${model.title}</h3>
<p class="price" data-sly-test="${model.priceAvailable}">${model.formattedPrice}</p>
<p class="price price--unavailable" data-sly-test="${!model.priceAvailable}">${model.formattedPrice}</p>
</div>
Every field uses a specific injector annotation (@ValueMapValue, @OSGiService, @SlingObject) — none of them rely on generic @Inject — and the fallback title is handled with @Default rather than a null check scattered into the getter, which is what keeps this readable as more fields get added later.
Summary
- Build a model when a component has real logic or needs to back both HTL rendering and a headless export — not for a component that's a pure static pass-through
- Silent adaptation failures on edge-case content are the most common real "why does this render empty" bug — check whether the model adapted at all before debugging the logic inside it
- A model accumulating unrelated responsibilities over time is the same smell as an overloaded OSGi service — split it before it becomes untestable
- Use specific injector annotations (
@ValueMapValue,@ChildResource,@OSGiService) instead of generic@Injectto avoid ambiguous resolution - For a component with one or two static properties and no logic, direct ValueMap access in HTL is simpler and has nothing to adapt or fail
What's Next
Next in this pass: Content Fragments — where they're genuinely the right content model versus when a plain page or component dialog would serve the same content need with far less structural overhead.
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.