Maven, Packages and the AEM Project Structure
25 – Maven, Packages and the AEM Project Structure
Target: 3–4 years experienced AEM Developer Covers from your additional list: Maven · Package Manager · Deployment process · project structure Project domain: a global energy technology company's marketing site.
Before we start — the question underneath this topic
This topic has been referenced from almost every earlier file without ever being explained. ui.apps, ui.content, ui.config, all, filter.xml, mode="merge" — they've appeared in files 01, 03, 04, 06, 13 and 14 as things you should know, and this is where they get explained.
But the interview question is rarely "list the Maven modules." It's:
"How does your code actually get from your laptop onto a production AEM instance?"
That's a question about the whole chain — modules, packages, filters, build, deploy. And the part that separates a good answer is understanding why the modules are split the way they are, because it isn't arbitrary. It follows directly from the immutable/mutable distinction in file 01:
/appsis code./contentand/confare data. They have different lifecycles, so they ship in different packages.
Get that, and the module structure stops being a list to memorise and becomes something you can derive.
1. Introduction
1.1 What a package actually is
Before Maven, the simpler concept:
An AEM package is a ZIP file containing repository content, plus a
filter.xmlthat declares exactly which paths are included.
That's it. It's built by a tool called FileVault (often abbreviated vlt), which serialises repository nodes into files on disk and back again.
Two things follow from that definition, and both matter:
The filter.xml is what defines the package, not the folder structure. You can have files sitting in your project that aren't in any filter, and they simply won't be deployed. That surprises people — "it's in my project, why isn't it on the server?"
Installing a package writes to the repository. It's a content operation. Which means it can overwrite things, and by default it does — section 2.4.
1.2 Why the project is split into modules
Here's the reasoning, and it's the answer to "why so many modules?"
Your project contains fundamentally different kinds of thing:
Those have genuinely different lifecycles, and mixing them causes real problems. The worst one: if pages and components ship in the same package, redeploying your components would overwrite author content.
So the modules exist to keep them apart. Once you see it that way, the structure is obvious rather than arbitrary.
1.3 A real project example to adapt
"We use the standard AEM archetype structure —
corefor the Java bundle,ui.appsfor components and clientlibs,ui.contentfor our templates and sample content,ui.configfor OSGi configs,ui.frontendas a webpack build,dispatcherfor the web tier, andallas the aggregate that Cloud Manager actually deploys.The split that matters most is
ui.appsversusui.content.ui.appsis immutable — on Cloud Service it's baked into the container image and read-only at runtime.ui.contentis mutable content, and every one of its filters usesmode="merge"so a deployment doesn't wipe the templates and policies our content leads have adjusted.Day to day I mostly deploy just the module I've changed —
-pl corefor a Java change is much faster than rebuilding everything — and only build the fullallpackage when I need to verify the whole thing."
That covers the structure, the reason for the key split, and the practical workflow — three follow-ups pre-empted.
2. Core Concepts
2.1 The modules, and what each is for
The AEM Project Archetype generates this structure. Learn what each module is for rather than just its name.
Going through them properly:
core — all your Java. Sling Models, OSGi services, servlets, filters, workflow process steps, job consumers. It compiles into an OSGi bundle, which is a JAR with the manifest headers from file 06.
ui.apps — everything under /apps. Components, their dialogs and HTL, clientlibs, template types. This is immutable content — on Cloud Service it's baked into the container image and read-only at runtime (file 14).
ui.content — everything under /content and /conf. Editable templates, policies, sample or initial content, Content Fragment Models. This is mutable content, which authors change at runtime.
ui.config — OSGi configurations, organised into run-mode folders (file 06). Technically these live under /apps, but the archetype separates them because configuration changes for different reasons than code does.
ui.frontend — an npm and webpack project (file 04). The front-end team works here with SCSS and TypeScript, and the build output is copied into a clientlib in ui.apps.
dispatcher — the dispatcher configuration (file 19). On Cloud Service this is deployed by the web-tier pipeline, which is much faster than a full-stack one.
it.tests and ui.tests — integration and UI tests, run by the Cloud Manager pipeline (file 14).
all — and this one needs explaining, because its purpose isn't obvious.
2.2 Why the all package exists
The all module contains no content of its own. It's an aggregate — a package whose only job is to embed the other packages.
Why that's necessary: Cloud Manager deploys exactly one package. It doesn't take a list. So something has to bundle core, ui.apps, ui.content and ui.config into a single deployable artefact, and that's all.
And it does one more thing that matters: it declares the order things install in, and it separates the mutable and immutable parts so the Cloud Service build validation can check them.
The interview answer:
"The
allpackage is an aggregate — it contains nothing itself, it just embeds the other packages. It exists because Cloud Manager deploys exactly one package rather than taking a list, so something has to bundle the bundle, the apps content, the mutable content and the configs into one artefact. It also declares the install order, and keeps the mutable and immutable parts distinguishable so the Cloud Service build validation can check that we haven't mixed them."
2.3 filter.xml — what actually defines a package
This is the most important file in the packaging story, and the most commonly misunderstood.
The filter declares which repository paths this package covers. Not which files are in your project folder — which paths the package claims.
Two consequences that catch people out:
A file in your project that isn't under a filter root won't be deployed. It builds fine, it's in the JAR, and it never reaches the repository. "It works locally but not on the server" is often exactly this — locally you'd copied it manually at some point.
A filter root claims that entire path — which brings us to the next section, and the single most dangerous property in AEM packaging.
2.4 mode="merge" — the property that stops you destroying content
By default, installing a package REPLACES everything under its filter root.
Read that again, because the consequence is severe. If ui.content has:
then every deployment deletes everything under /content/energy and replaces it with whatever is in the package. Every page an author has created since the last release, gone.
mode="merge" changes that:
With merge, existing content is left alone and only nodes present in the package are added or updated.
The filter modes:
The rule to state:
Anything under
/contentor/confmust usemode="merge". Those are paths authors change. Anything under/appscan use the defaultreplace, because that's your code and a clean replace is what you want.
This is a genuinely good thing to raise unprompted, because getting it wrong destroys production content and it's a mistake people only make once.
2.5 Filter rules — include and exclude
Filters can be more precise than a root path:
exclude removes paths from a filter that would otherwise be covered. include narrows a filter to only matching paths.
A practical use: excluding an install folder so a nested package isn't accidentally shipped, or including only a specific asset folder rather than the whole DAM.
2.6 The Maven build
Maven is the build tool. The parent pom.xml declares the modules and the shared configuration; each module has its own POM.
The commands you'll actually use — and knowing the targeted ones is a small signal that you develop day to day rather than only building everything:
The -pl flag means "project list" — build only that module. That's the difference between a fifteen-second deploy and a two-minute one, and during active development it matters a lot.
The profiles (-P) are defined in the POM and control what happens after the build — autoInstallSinglePackage uploads and installs the all package; autoInstallBundle uploads just the bundle.
2.7 Dependency scopes — the one that breaks bundles
This is the Maven detail most likely to come up, because getting it wrong produces a confusing OSGi failure.
AEM's APIs come from a dependency — the uber-jar on 6.5, or the aem-sdk-api on Cloud Service. And the scope you give it matters:
provided means "this will be available at runtime; don't package it."
If you use compile instead — the default — Maven embeds a copy of the AEM API into your bundle. Now there are two copies of the same packages in the OSGi container at potentially different versions, and your bundle's imports can't be satisfied.
The symptom is the file 06 problem: the bundle sits in INSTALLED state, never activates, and your code silently never runs.
So the chain to be able to state: wrong Maven scope → duplicate packages → unsatisfied Import-Package → bundle stuck in INSTALLED → your code never runs, with no error. That's a genuinely good answer because it connects a build-tool detail to a runtime symptom.
The scopes worth knowing:
2.8 Embedding third-party libraries
If your code needs a library AEM doesn't provide, you have to get it into the OSGi container.
Two approaches:
Embed it in your bundle — the library is packaged inside your JAR and its packages are private to you. Simple, and the right answer for a small utility library.
Deploy it as its own bundle — if the library is already an OSGi bundle, or several of your bundles need it. Cleaner, but more moving parts.
The thing to watch: embedding a library that AEM already provides at a different version causes exactly the duplicate-package problem from 2.7. Before embedding anything, it's worth checking whether AEM already exports it — /system/console/depfinder tells you which bundle exports a given package (file 06).
2.9 Package Manager
Package Manager at /crx/packmgr is the UI for uploading, installing, building and downloading packages.
What it's genuinely useful for:
Building a content package to move content between environments — export from one, install on another.
Inspecting a package — what filters does it have, what's actually inside it.
Downloading what's currently installed.
And the important caveat: you can't use it to deploy on Cloud Service production. Deployment is Cloud Manager pipelines only (file 14). Package Manager still exists on development environments, but the deployment path is closed deliberately — that's the discipline benefit from file 14, and it means production can't drift from Git.
On 6.5 you can deploy via Package Manager, and plenty of teams did. It's also exactly how production drifts from source control, which is the argument against it.
2.10 Content packages for moving content
A practical use worth knowing, and it comes up as a scenario question.
Moving content from production to a lower environment for debugging:
- Build a package on production with a filter covering just the paths you need
- Download it
- Install it on the lower environment
Three things to be careful about:
Filter precisely. A filter of /content on a large site produces an enormous package.
Exclude what you shouldn't copy — /home (users and their data), anything containing credentials, and personal data. This is a genuine data-protection concern, not just tidiness.
Watch for mode="replace" — installing a content package on the target will replace what's there unless you set merge.
On Cloud Service there's a Content Copy feature in Cloud Manager for exactly this, which is safer than hand-built packages.
2.11 The mutable/immutable split on Cloud Service
This is where the module structure becomes mandatory rather than good practice (file 14).
On Cloud Service, the build validates that packages don't mix mutable and immutable content. If ui.apps contains anything under /content, or ui.content contains anything under /apps, the build fails.
Which is why the split exists as a hard rule:
A common migration finding is a project where components and sample content shipped together in one package, which worked fine on 6.5 and fails validation on Cloud Service. That's what the Repository Modernizer tool restructures (file 14).
3. Internal Working
3.1 From your laptop to the repository
graph TD A["mvn clean install"] --> B["core → compiled into an OSGi BUNDLE"] A --> C["ui.frontend → webpack build"] C --> D["output copied into a clientlib in ui.apps"] A --> E["ui.apps → FileVault ZIP (immutable)"] A --> F["ui.content → FileVault ZIP (mutable)"] A --> G["ui.config → FileVault ZIP"] B --> H["ALL package embeds them"] E --> H F --> H G --> H H --> I{"Local, or Cloud Manager?"} I -->|Local| J["Uploaded to Package Manager<br/>and installed"] I -->|Cloud| K["Pipeline: quality gates →<br/>images → stage → production"]
Two things worth drawing out:
ui.frontend builds first, and its output lands in ui.apps before that package is assembled. That's why editing the generated css.txt by hand gets overwritten (file 04) — the build regenerates it every time.
The bundle is embedded in the package. Your Java doesn't deploy separately; it's inside ui.apps (or all) in an install folder, and the Sling Installer picks it up.
3.2 How a package installs
graph TD A["Package uploaded"] --> B["Read filter.xml"] B --> C{"For each filter root:<br/>what mode?"} C -->|"replace (default)"| D["DELETE everything under the root"] C -->|"merge"| E["Leave existing nodes alone"] D --> F["Write the package content"] E --> F F --> G{"Any bundles in an<br/>install folder?"} G -->|Yes| H["Sling Installer picks them up<br/>→ OSGi bundle lifecycle (file 06)"] G -->|No| I["Done"]
The left branch is the dangerous one, and it's why mode="merge" matters so much on content paths. A default-mode filter on /content/energy deletes every page authors created since the last release, and the package installs successfully — no error, because it did exactly what it was told.
3.3 The Sling Installer
Worth knowing because it explains how a bundle inside a content package becomes a running bundle.
The Sling Installer watches /apps/**/install and /libs/**/install. When a JAR or a .cfg.json appears there, it picks it up — installing bundles and applying configurations.
Which is why: your compiled bundle ends up in an install folder inside the package, and OSGi configurations end up in config.<runmode> folders. The installer handles both, and everything from file 06's bundle lifecycle takes over from there.
8. Configuration Examples
8.1 ui.apps filter — immutable content
ui.apps/src/main/content/META-INF/vault/filter.xml
8.2 ui.content filter — mutable content
ui.content/src/main/content/META-INF/vault/filter.xml
The mode="merge" comment is the thing to be able to explain, and it's story 1.
8.3 The AEM API dependency
core/pom.xml
8.4 The commands worth memorising
-pl is the flag worth knowing — it means "project list", building only that module. During active development that's the difference between a fifteen-second cycle and a two-minute one, and knowing it is a small signal that you develop day to day.
8.5 A content package filter for moving content between environments
The Content Fragment filter is the detail worth pointing at — it's the same trap as file 15 and file 18. Fragments are separate assets, so packaging the pages that reference them doesn't include them, exactly as publishing the pages doesn't publish them.
9. Common Mistakes
10. Best Practices
On filters. mode="merge" on every /content and /conf filter, without exception, checked in review rather than assumed. Filter precisely. Never include /home.
On modules. Keep the mutable/immutable split clean — it's enforced on Cloud Service anyway, and it's right on 6.5 too. Don't split ui.apps per brand; share the code and differentiate through policies and configuration.
On dependencies. provided for AEM APIs, test for test libraries, compile only for third-party libraries AEM genuinely doesn't have. Check depfinder before embedding anything.
On the build. Deploy targeted while iterating, full build before pushing. Never skip tests in CI.
On deployment. Pipeline only, even where a manual route technically exists. Anything created by hand doesn't exist on the next environment — that's the file 13 and 14 lesson, and packaging is where it bites.
11. Debugging Tips
"It works locally but not on the server." This is the signature packaging problem, and the check is: inspect the built package, not the project folder. Download the artefact from Package Manager, open it, and see whether the file is actually there. The project folder tells you what you wrote; the package tells you what deploys.
"My bundle isn't active." /system/console/bundles — INSTALLED means an unsatisfied import, which very often traces back to a Maven scope. /system/console/depfinder tells you which bundle exports a given package.
"Content disappeared after a deployment." A filter without mode="merge". Check the filter file for the affected path, and check whether the deployment reported success — it will have.
"The configuration isn't applying." Check the run-mode folder name and the file name against the component PID (file 06), and confirm the file is actually under a filter root.
12. Performance Notes
Build time is developer time. Targeted deploys with -pl are the single biggest saving during active development.
Package size matters for install time. A content package covering a large subtree takes real time to install and can time out. Filter precisely.
A large content package on production is an operation, not a background task — it writes a lot of nodes and competes with authoring.
The Cloud Manager pipeline duration is dominated by the quality gates and stage tests, not the build. Which is why the faster pipeline types exist (file 14) — a dispatcher rule change doesn't need the full cycle.
15. Comparison Tables
The modules
Filter modes
Maven scopes
Deployment routes
Next topic
26-Cloud-Manager-Pipelines-and-CI-CD — Cloud Manager in depth, the pipeline types and quality gates, Git strategy and branching, environments, and what "CI/CD" actually looks like on an AEM project.
Topic 25 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.