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.
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:
bundle-aexports the package that class lives in (Export-Package), andbundle-bimports that same package (Import-Package), or embeds the class directly inside its own jar.
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:
- You add a utility method to a shared
coremodule. - You call it from a servlet, Sling Model, or scheduled job in the same Maven reactor.
mvn installsucceeds. Unit tests, which run on the plain JVM classpath with no OSGi involved, pass.- You deploy to AEM. The bundle installs and even activates, because the package reference might be resolved lazily.
- 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
NoClassDefFoundErrororClassNotFoundExceptionfor 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
- A shared “common” or “core” utility module. A developer adds a class to a shared module, forgets to export the package it lives in (or bnd’s default non-export behavior silently excludes it), and every consumer bundle that isn’t in the same reactor build fails at runtime even though the reactor build itself compiles cleanly.
- A transitive dependency that isn’t itself an OSGi bundle. Plenty of
ordinary Java libraries were never built with OSGi in mind and have no
Export-Packageheader of their own. If your bundle needs one of their classes, you either embed it (bnd’s-includeresource/Embed-Dependencystyle instructions) or you provide it as a separate installed bundle — simply having it as a Maven<dependency>is not enough, and this is precisely the case/system/console/depfinderis built to catch. - Unit tests that give false confidence. Because tests run outside Felix, a service can have 100% passing tests and still fail the moment it’s deployed, if the class it depends on isn’t actually wired at the OSGi level. Treat green unit tests as proof the logic is correct, not proof the bundle will resolve.