AEM Guide

Why Your Java Class Can't See That Other Class: OSGi Bundle Classloading in AEM

Why a class that compiles fine in Maven can still throw ClassNotFoundException in AEM, how Import-Package/Export-Package control bundle visibility, and how to debug it with the Felix web console.

osgifelixclassloadingmavendebugging

In a plain Java application, once a jar is on the classpath, every class in it can see every other class on the classpath. AEM doesn’t work that way. AEM runs on Apache Felix, an OSGi framework, and OSGi’s module layer gives every deployed bundle (an OSGi jar) its own classloader. Two bundles sitting side by side in /system/console/bundles, both built from the same Maven reactor, cannot see each other’s classes unless the bundle metadata explicitly says so. This is the single most common cause of “it compiled, it deployed, and then it threw ClassNotFoundException” in AEM.

Why the Maven classpath isn’t the runtime classpath

When you run mvn package on an AEM project, Maven resolves the full transitive dependency tree and puts it on a single classpath for compilation and (if you have them) unit tests. That classpath has no concept of bundles, imports, or exports — it’s flat.

AEM’s runtime is a different world. Felix installs each bundle with its own classloader and wires bundles together only through the packages they explicitly export and import, as declared in the bundle’s manifest (META-INF/MANIFEST.MF). A class from bundle-a is invisible to bundle-b unless:

This is true even if both bundles came from the same Maven module, the same reactor build, or the same team’s “shared” library — Maven visibility and OSGi visibility are two unrelated mechanisms that happen to look similar on the surface.

Import-Package and Export-Package: the real AEM classpath

Every OSGi bundle carries manifest headers that describe its dependency contract:

Export-Package: com.mysite.core.util;version="1.0.0"
Import-Package: org.apache.commons.lang3;version="[3.0,4)",*

Export-Package says “other bundles may use these packages of mine.” Import-Package says “I need these packages, supplied by whichever bundle exports them, at this version range.” When Felix starts a bundle, it tries to wire each Import-Package entry to a matching Export-Package from another active bundle. If it can’t find one, the bundle stays in the Installed state instead of becoming Active — or, for optional/dynamic imports, it activates fine and only fails the moment code actually touches the missing package.

Current AEM projects (the aem-project-archetype moved off maven-bundle-plugin some time ago) generate these headers with the bnd-maven-plugin, which is built on the same bnd tool Apache Sling itself uses. Unlike the older Maven Bundle Plugin, bnd-maven-plugin does not export every package by default — you opt in, typically by annotating a package with @org.osgi.annotation.bundle.Export in package-info.java:

// core/src/main/java/com/mysite/core/util/package-info.java
@org.osgi.annotation.bundle.Export
package com.mysite.core.util;

or with an explicit Export-Package bnd instruction in the module’s pom.xml:

<plugin>
  <groupId>biz.aQute.bnd</groupId>
  <artifactId>bnd-maven-plugin</artifactId>
</plugin>

Import-Package is usually left to bnd’s own bytecode analysis: it scans the compiled classes for every package your code actually references and generates the import list automatically, resolving version ranges from the dependencies declared in the POM. This is convenient, but it’s also exactly why a missing export elsewhere in your project shows up as a runtime surprise rather than a build error — bnd can only import what some bundle is prepared to export.

Why it compiles fine and still breaks at runtime

Maven only cares whether a class is reachable on the compile classpath. It has no idea whether that class will be visible across a bundle boundary once deployed. So a very common sequence looks like this:

  1. You add a utility method to a shared core module.
  2. You call it from a servlet, Sling Model, or scheduled job in the same Maven reactor.
  3. mvn install succeeds. Unit tests, which run on the plain JVM classpath with no OSGi involved, pass.
  4. You deploy to AEM. The bundle installs and even activates, because the package reference might be resolved lazily.
  5. The code path that calls the utility method runs for the first time — in a request, a workflow step, or a scheduled job — and AEM throws NoClassDefFoundError or ClassNotFoundException for a class that, by every Maven signal, “exists.”

The gap is Export-Package/Import-Package wiring, not compilation. Unit tests don’t catch it because they never run inside Felix; they run on a flat classpath where OSGi visibility rules simply don’t apply.

Debugging it with the Felix web console

The Felix web console is the primary tool for diagnosing this in AEM, whether locally on author (http://localhost:4502/system/console) or on a Cloud Service dev environment.

/system/console/bundles lists every installed bundle with its state. A bundle stuck in Installed (rather than Active) usually means Felix couldn’t satisfy one of its Import-Package entries. Clicking into a specific bundle shows its full manifest, including the Imported Packages and Exported Packages sections — imports that resolved show which bundle is supplying them; imports that didn’t resolve are listed as unsatisfied, which tells you immediately that no active bundle in the framework currently exports that package at a compatible version.

/system/console/depfinder takes a fully-qualified class or package name and tells you which bundle (or which Maven dependency, if it isn’t present at all) provides it. It’s the fastest way to answer “is this class available anywhere in this AEM instance, and under what package version,” before you go chasing a NoClassDefFoundError through your own code.

Between the two: use bundles to confirm your bundle’s own import/export wiring, and depfinder to find out where a missing class should actually be coming from.

Where this comes up in real AEM projects