Spun up a clean AEM local today and wrote down the bits that keep my Maven and HTL setup neat and readable.
Maven first, because future me likes boring builds
When a project starts small and then grows new features each week, a tidy Maven reactor saves the day. The classic trio core, ui.apps, and ui.content keeps code and content in their lanes. I pin the uber jar and stick to one source of truth for versions, so no random mix of libs sneaks in. It is not fancy, just predictable. That alone pays for itself when the team grows or a teammate needs to build on a fresh laptop.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.adobe.aem</groupId>
<artifactId>uber-jar</artifactId>
<version>6.4.0</version>
<scope>provided</scope>
<type>jar</type>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.jackrabbit</groupId>
<artifactId>filevault-package-maven-plugin</artifactId>
<version>1.0.4</version>
</plugin>
<plugin>
<groupId>org.apache.sling</groupId>
<artifactId>maven-sling-plugin</artifactId>
<version>2.4.0</version>
</plugin>
</plugins>
</build>Pin the Uber Jar to match the target AEM line you use. On my desk that is 6.4 right now. If you are on a different minor, align the version.
Project bootstrap in one command
For new projects I let the Adobe project archetype do the heavy lifting. It wires the modules, gives me sample content, and a ready to run filter set. Clean slate. I only tweak names and version numbers. Make sure you are on Java 8 and your local author is running before you start pushing packages.
mvn -B archetype:generate \
-DarchetypeGroupId=com.adobe.granite.archetypes \
-DarchetypeArtifactId=aem-project-archetype \
-DarchetypeVersion=12 \
-DgroupId=com.example.site \
-DartifactId=example-site \
-Dversion=1.0.0-SNAPSHOT \
-Dpackage=com.example.site \
-DappsFolderName=example \
-DartifactName="Example Site" \
-DcomponentGroupName="Example Site" \
-DconfFolderName=example \
-DcontentFolderName=example \
-DpackageGroup="Example"You get a working ui.apps with filter rules, a core bundle ready for Sling Models, and a tiny style system sample. From there you can focus on content types and real features instead of wiring.
HTL that reads like a story
HTL keeps markup clean and moves Java code to models where it belongs. No scriptlets, no soup. Output is escaped by default, which means fewer late night surprises with user input. I like to keep templates small and readable, and push anything smart to a model. The page still shows what it outputs, and the Java side holds the rules.
<!-- /apps/example/components/teaser/teaser.html -->
<sly data-sly-use.model="com.example.site.core.models.TeaserModel"></sly>
<div class="teaser">
<h3 class="teaser__title">${model.title}</h3>
<sly data-sly-test="${model.imageSrc}">
<img src="${model.imageSrc @ context='uri'}" alt="${model.imageAlt}" />
</sly>
<p class="teaser__desc">${model.description}</p>
<sly data-sly-list.link="${model.links}">
<a class="teaser__link" href="${link.url @ context='uri'}">${link.label}</a>
</sly>
</div>package com.example.site.core.models;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;
import javax.annotation.PostConstruct;
import java.util.List;
@Model(adaptables = Resource.class, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class TeaserModel {
@ValueMapValue
private String title;
@ValueMapValue
private String description;
@ValueMapValue(name = "image")
private String imageSrc;
@ValueMapValue(name = "imageAlt")
private String imageAlt;
private List<Link> links;
@PostConstruct
protected void init() {
// fetch child resources or content policies as needed
}
public String getTitle() { return title; }
public String getDescription() { return description; }
public String getImageSrc() { return imageSrc; }
public String getImageAlt() { return imageAlt; }
public List<Link> getLinks() { return links; }
public static class Link {
public String url;
public String label;
}
}Notice how the template reads like plain markup. The model offers simple getters. You can test the Java on its own, and authors still see tidy HTML. Separation of concerns without drama.
Fast local loop
For day to day work I keep a short set of build profiles. One for the full build, one to push the whole content package, and one for just the bundle. Ship fast, test right away, repeat. Nothing fancy, just quick feedback.
# full build
mvn clean install
# install all packages to local author
mvn clean install -PautoInstallPackage
# only the bundle when Java changed
mvn -pl core -am clean install -PautoInstallBundle
# only content when dialogs or HTL changed
mvn -pl ui.apps,ui.content -am clean install -PautoInstallSinglePackageSmall extra tips from the trenches. Keep author and publish run modes separate to avoid surprises. Add a neat filter.xml and never let it scoop up temp nodes. Use ClientLibs categories and one entry file per feature so front end stays tidy. And write a quick readme in the root with commands your future self will forget.
AEM with Maven and HTL can be simple when you aim for clear modules, clean models, and readable templates.
Ship small, keep code honest, and let authors see the good stuff.