Skip to content

Porting ClassGraph 4.x code to ClassGraph 5.x

Luke Hutchison edited this page Aug 15, 2026 · 17 revisions

ClassGraph 5.x does not maintain source or binary compatibility with ClassGraph 4.x. This page is a complete, step-by-step guide to porting a project from the 4.x API to the 5.x API.

Most of the work is mechanical renaming, and the compiler finds nearly all of it for you. The part that needs attention is Step 8: a small number of changes leave your code compiling, but make it behave differently.

Contents

Porting with Claude Code

This guide is written to be followed literally, by a person or by a coding agent. If you use Claude Code, open a terminal in your project's git repository and give it this prompt:

Port this repository from the ClassGraph 4.x API to the ClassGraph 5.x API, by following https://github.com/classgraph/classgraph/wiki/Porting-ClassGraph-4.x-code-to-ClassGraph-5.x exactly. Work through the steps in order. Do not skip Step 8 — those are the changes that still compile but behave differently, so the compiler will not find them for you. When you are done, build the project and run its tests, and tell me every place where you had to make a judgment call.

Commit your work first, so that you can read the diff afterwards and revert if you need to.

Two things to tell it if they apply to your project: that you are porting a library whose own API exposes ClassGraph types (so the renames propagate to your users), and any place where you rely on ClassGraph's behavior in a way the guide should not silently change.

Step 1: Update the dependency

ClassGraph 4.x shipped as one jar. ClassGraph 5.x is split into several, so that a project depends only on the part of ClassGraph it uses. Each artifact depends on the one above it:

Artifact Java module Contents
io.github.classgraph:classgraph-base io.github.classgraph.base Shared internals; no API of its own
io.github.classgraph:classgraph-vfs io.github.classgraph.vfs A read-only virtual filesystem over directories, jarfiles and modules
io.github.classgraph:classgraph-classpath io.github.classgraph.classpath Finding the classpath and the module path
io.github.classgraph:classgraph io.github.classgraph Scanning, and the class graph API
io.github.classgraph:classgraph-viz io.github.classgraph.viz GraphViz .dot file generation

If you use the scanning API (new ClassGraph(), ScanResult, ClassInfo and the rest), keep depending on classgraph — the artifact id and group id are unchanged, and the other artifacts come in transitively:

<dependency>
    <groupId>io.github.classgraph</groupId>
    <artifactId>classgraph</artifactId>
    <version>5.0.0</version>
</dependency>

If you also generate GraphViz .dot files, add classgraph-viz as well — the generators are no longer in the core artifact (see Step 6).

If you use only the classpath finder or only the virtual filesystem, you can now depend on classgraph-classpath or classgraph-vfs alone. See the new capabilities.

ClassGraph 5.x requires JDK 17 or newer, to build and to run. If your project still targets JDK 8 through 16, stay on the last 4.x release; it is not going away.

Step 2: Update module-info.java

If your project is modular, the requires io.github.classgraph; line still covers the scanning API, and nothing needs to change. Add a requires line for each further ClassGraph module you use:

requires io.github.classgraph;          // Scanning and the class graph API
requires io.github.classgraph.viz;      // Only if you generate .dot files
requires io.github.classgraph.classpath; // Only if you use ClasspathFinder directly
requires io.github.classgraph.vfs;      // Only if you use Vfs directly

If you use maven-shade-plugin, the packages to relocate have changed: there is no longer a nonapi top-level package, so relocating io.github.classgraph now covers all of ClassGraph. See Shading ClassGraph.

Step 3: Rename the deprecated methods

Everything that was deprecated in 4.x has been removed. Each has a direct replacement with the same behavior, so these are pure renames:

ClassGraph 4.x ClassGraph 5.x
ClassGraph#whitelistPackages ClassGraph#acceptPackages
ClassGraph#whitelistPackagesNonRecursive ClassGraph#acceptPackagesNonRecursive
ClassGraph#whitelistPaths ClassGraph#acceptPaths
ClassGraph#whitelistPathsNonRecursive ClassGraph#acceptPathsNonRecursive
ClassGraph#whitelistClasses ClassGraph#acceptClasses
ClassGraph#whitelistJars ClassGraph#acceptJars
ClassGraph#whitelistModules ClassGraph#acceptModules
ClassGraph#whitelistClasspathElementsContainingResourcePath ClassGraph#acceptClasspathElementsContainingResourcePath
ClassGraph#blacklistPackages ClassGraph#rejectPackages
ClassGraph#blacklistPaths ClassGraph#rejectPaths
ClassGraph#blacklistClasses ClassGraph#rejectClasses
ClassGraph#blacklistJars ClassGraph#rejectJars
ClassGraph#blacklistModules ClassGraph#rejectModules
ClassGraph#blacklistClasspathElementsContainingResourcePath ClassGraph#rejectClasspathElementsContainingResourcePath
ScanResult#getResourcesWithPathIgnoringWhitelist ScanResult#getResourcesWithPathIgnoringAccept
FieldInfo#getModifierStr FieldInfo#getModifiersString

Step 4: Rename the hierarchy and annotation queries

In 4.x, getSubclasses(), getInterfaces(), getAnnotationInfo() and the rest returned the transitive answer, and you narrowed it to the directly-related classes by calling .directOnly(). Nothing in the name said which one you were getting. Each query is now spelled out as either getAll... (transitive — what 4.x returned) or getDirect... (what .directOnly() gave you).

Read this table carefully. The 4.x name is gone, so your code will not compile until you choose one of the two columns, and the safe default — the one that preserves 4.x behavior — is the middle column, not the right one.

ClassGraph 4.x Same behavior as 4.x Direct only
ClassInfo#getSubclasses() ClassInfo#getAllSubclasses() ClassInfo#getDirectSubclasses()
ClassInfo#getSuperclasses() ClassInfo#getAllSuperclasses() ClassInfo#getSuperclass() (single)
ClassInfo#getInterfaces() ClassInfo#getAllSuperinterfaces() ClassInfo#getDirectSuperinterfaces()
ClassInfo#getClassesImplementing() ClassInfo#getAllClassesImplementing() ClassInfo#getDirectClassesImplementing()
ClassInfo#getSubinterfaces() ClassInfo#getAllSubinterfaces() ClassInfo#getDirectSubinterfaces()
ClassInfo#getAnnotations() ClassInfo#getAllAnnotations() ClassInfo#getDirectAnnotations()
ClassInfo#getAnnotationInfo() ClassInfo#getAllAnnotationInfo() ClassInfo#getDirectAnnotationInfo()
ClassInfo#getAnnotationInfo(Class | String) ClassInfo#getAllAnnotationInfo(Class | String) ClassInfo#getDirectAnnotationInfo(Class | String)
ClassInfo#getAnnotationInfoRepeatable(Class | String) ClassInfo#getAllAnnotationInfoRepeatable(...) ClassInfo#getDirectAnnotationInfoRepeatable(...)
MethodInfo / FieldInfo #getAnnotationInfo(...) #getAllAnnotationInfo(...) #getDirectAnnotationInfo(...)
MethodInfo / FieldInfo #getAnnotationInfoRepeatable(...) #getAllAnnotationInfoRepeatable(...) #getDirectAnnotationInfoRepeatable(...)
MethodParameterInfo#getAnnotationInfo(...) #getAllAnnotationInfo(...) #getDirectAnnotationInfo(...)
MethodParameterInfo#getAnnotationInfoRepeatable(...) #getAllAnnotationInfoRepeatable(...) #getDirectAnnotationInfoRepeatable(...)
PackageInfo / ModuleInfo #getAnnotationInfo(...) (see the warning below) #getDirectAnnotationInfo(...)
ScanResult#getSubclasses(Class | String) ScanResult#getAllSubclasses(...) ScanResult#getDirectSubclasses(...)
ScanResult#getSuperclasses(Class | String) ScanResult#getAllSuperclasses(...) getSuperclass() on the ClassInfo
ScanResult#getInterfaces(Class | String) ScanResult#getAllSuperinterfaces(...) ScanResult#getDirectSuperinterfaces(...)
ScanResult#getClassesImplementing(Class | String) ScanResult#getAllClassesImplementing(...) ScanResult#getDirectClassesImplementing(...)
ScanResult#getSubinterfaces(Class | String) ScanResult#getAllSubinterfaces(...) ScanResult#getDirectSubinterfaces(...)
ScanResult#getAnnotationsOnClass(String) ScanResult#getAllAnnotationsOnClass(...) ScanResult#getDirectAnnotationsOnClass(...)

Five notes:

  • PackageInfo and ModuleInfo are the exception. Their 4.x getAnnotationInfo(...) returned only the annotations written on package-info.class / module-info.class, without expanding meta-annotations. So for these two classes, the 4.x behavior is getDirectAnnotationInfo(...), not getAllAnnotationInfo(...). Both classes now carry the same twelve-method annotation surface as ClassInfo, so getAllAnnotationInfo() on a package or module now expands meta-annotations, and hasAnnotation() (which asks the "all" list) answers true for a meta-annotation.
  • .directOnly() still works, on ClassInfoList and AnnotationInfoList. If your code already says getSubclasses().directOnly(), getAllSubclasses().directOnly() is a correct port; but getDirectSubclasses() is the direct way to ask.
  • The reverse queries deliberately keep their names: ScanResult#getClassesWithAnnotation(...), #getClassesWithAllAnnotations(...), #getClassesWithAnyAnnotation(...), #getClassesWithMethodAnnotation(...), #getClassesWithFieldAnnotation(...) and the rest. They return the transitive answer, as in 4.x.
  • ClassInfoList#getInterfaces() and ClassInfoList#getAnnotations() are unchanged. They are list filters ("keep the entries of this list that are interfaces"), not hierarchy queries. Do not rename them.
  • The no-argument ScanResult#getAllInterfaces() — "every interface in the scan result" — is unchanged. Only the two per-class overloads are renamed.

All twelve annotation methods now come from a new public interface, HasAnnotations, implemented by ClassInfo, FieldInfo, MethodInfo, MethodParameterInfo, PackageInfo and ModuleInfo. This is additive — every method keeps its name and signature — but it lets you write a method that accepts any annotated element:

static boolean isDeprecated(HasAnnotations element) {
    return element.hasAnnotation(Deprecated.class);
}

Step 5: Rename everything else

Str is now spelled String

ClassGraph 4.x ClassGraph 5.x
ClassInfo#getModifiersStr() ClassInfo#getModifiersString()
ClassInfo#getTypeSignatureStr() ClassInfo#getTypeSignatureString()
ClassMemberInfo#getModifiersStr() ClassMemberInfo#getModifiersString()
ClassMemberInfo#getTypeDescriptorStr() ClassMemberInfo#getTypeDescriptorString()
ClassMemberInfo#getTypeSignatureStr() ClassMemberInfo#getTypeSignatureString()
ClassMemberInfo#getTypeSignatureOrTypeDescriptorStr() ClassMemberInfo#getTypeSignatureOrTypeDescriptorString()
FieldInfo#getModifiersStr() FieldInfo#getModifiersString()
MethodInfo#getModifiersStr() MethodInfo#getModifiersString()
MethodParameterInfo#getModifiersStr() MethodParameterInfo#getModifiersString()
ArrayClassInfo#getTypeSignatureStr() ArrayClassInfo#getTypeSignatureString()
ArrayTypeSignature#getTypeSignatureStr() ArrayTypeSignature#getTypeSignatureString()
BaseTypeSignature#getTypeStr() BaseTypeSignature#getTypeName()

BaseTypeSignature's method is the exception to the pattern: it returns the name of a primitive type ("int", "void"), not a signature string, so it is named after Class#getTypeName(), which returns the same string for the same type.

Getters, predicates and units

ClassGraph 4.x ClassGraph 5.x
Resource#getLastModified() Resource#getLastModifiedMillis()
ScanResult#classpathContentsLastModifiedTime() ScanResult#getClasspathContentsLastModifiedMillis()
ScanResult#classpathContentsModifiedSinceScan() ScanResult#isClasspathContentsModifiedSinceScan()
ModuleInfo#getLocation() ModuleInfo#getLocationURI()

Both time values were already in milliseconds since the epoch, and are unchanged; the names now say so. Resource#getLastModifiedMillis() still returns 0L when the time is unknown.

AnnotationInfo#getParameterValues(boolean) is now two methods

ClassGraph 4.x ClassGraph 5.x
getParameterValues() getParameterValues()
getParameterValues(true) getParameterValues()
getParameterValues(false) getDeclaredParameterValues()

getDefaultParameterValues(), which returns the defaults declared by the annotation type, is unchanged.

Modules are now java.lang.module.ModuleReference

ModuleRef was a wrapper written when ClassGraph had to run on JDK 7 and 8, where java.lang.module did not exist. It is gone, and the JDK's own type is used:

ClassGraph 4.x ClassGraph 5.x
ClassInfo#getModuleRef() ClassInfo#getModuleReference()
ModuleInfo#getModuleRef() ModuleInfo#getModuleReference()
Resource#getModuleRef() Resource#getModuleReference()
ScanResult#getModules() ScanResult#getModuleReferences()
ClassGraph#getModules() ClassGraph#getModuleReferences()

Then rewrite the accessor calls on the returned object:

ModuleRef (4.x) ModuleReference (5.x)
getName() descriptor().name()
getReference() the object itself
getDescriptor() descriptor()
getPackages() descriptor().packages()
getRawVersion() descriptor().rawVersion().orElse(null)
getLocation() location().orElse(null)
getLocationString() location().map(URI::toString).orElse(null)
open() open()

ModuleRef#getPackages() returned the package names sorted; descriptor().packages() returns them in no particular order. Sort them yourself if the order matters.

Four ModuleRef members have no equivalent:

  • getLayer() — you supplied the layer to addModuleLayer(), or it is ModuleLayer.boot(). ClassGraph no longer holds it, because holding it kept that layer's classloaders alive.
  • getClassLoader() — loading classes is now your job; see Step 6.
  • isSystemModule() — this was a scanning-time classification, not a property of a module, and is now internal to ClassGraph.
  • getLocationFile() — use location(), which gives you the URI.

Module arguments are now strongly typed

ClassGraph 4.x ClassGraph 5.x
ClassGraph#addModuleLayer(Object) ClassGraph#addModuleLayer(ModuleLayer)
ClassGraph#overrideModuleLayers(Object...) ClassGraph#overrideModuleLayers(ModuleLayer...)

If you already passed a ModuleLayer, no source change is needed — but the call must be recompiled.

ModuleReaderProxy has been removed

Open a module with ModuleReference#open(), which returns a java.lang.module.ModuleReader:

// ClassGraph 4.x
try (ModuleReaderProxy moduleReader = moduleRef.open()) {
    List<String> paths = moduleReader.list();
    ByteBuffer content = moduleReader.read(path);
}

// ClassGraph 5.x
try (ModuleReader moduleReader = moduleReference.open()) {
    List<String> paths = moduleReader.list().toList();
    ByteBuffer content = moduleReader.read(path).orElseThrow();
}

Three differences to handle: list() returns a Stream<String>, not a List<String>; open(String), read(String) and find(String) return Optional values and throw IOException rather than wrapping it in an unchecked exception; and close() throws IOException, where ModuleReaderProxy#close() swallowed it.

ModulePathInfo has moved and its fields are now getters

ModulePathInfo has moved from io.github.classgraph to io.github.classgraph.classpath — update the import. It is still reachable from ScanResult#getModulePathInfo(), and now also from Classpath#getModulePathInfo().

Its six public final Set<String> fields are now private, behind getters that return an unmodifiable Set<String>:

ClassGraph 4.x ClassGraph 5.x
modulePathInfo.modulePath modulePathInfo.getModulePath()
modulePathInfo.addModules modulePathInfo.getAddModules()
modulePathInfo.patchModules modulePathInfo.getPatchModules()
modulePathInfo.addExports modulePathInfo.getAddExports()
modulePathInfo.addOpens modulePathInfo.getAddOpens()
modulePathInfo.addReads modulePathInfo.getAddReads()

Step 6: Replace the removed features

Classloading

ClassGraph reads classfiles; it does not load them any more.

Class loading was the source of many of the hardest-to-understand bugs in 4.x. Loading a class means picking a classloader for it, and the right choice depends on details that cannot be reliably predicted from outside the application: which loader delegates to which, which one has the class visible to it, whether loading it will run static initializers with side effects, and what the framework the code is running under expects. 4.x tried to anticipate the right answer in every environment, and every environment it had not anticipated produced another bug. The application knows which classloader it wants; ClassGraph does not, so it no longer guesses.

Everything that turned a scan result into a live Class, Method, Field, Constructor, enum constant or annotation instance has been removed, along with the ClassGraphClassLoader that did the loading:

Removed in 5.x
ScanResult#loadClass(String, boolean) and #loadClass(String, Class<T>, boolean)
ClassInfo#loadClass(), #loadClass(boolean), #loadClass(Class<T>), #loadClass(Class<T>, boolean)
ClassInfo#getEnumConstantObjects()
ClassInfoList#loadClasses(), #loadClasses(boolean), #loadClasses(Class<T>), #loadClasses(Class<T>, boolean)
ArrayClassInfo#loadClass(), #loadClass(boolean), #loadElementClass(), #loadElementClass(boolean)
ArrayTypeSignature#loadClass(), #loadClass(boolean), #loadElementClass(), #loadElementClass(boolean)
ClassRefTypeSignature#loadClass() and #loadClass(boolean)
AnnotationClassRef#loadClass() and #loadClass(boolean)
AnnotationEnumValue#loadClassAndReturnEnumValue() and #loadClassAndReturnEnumValue(boolean)
AnnotationInfo#loadClassAndInstantiate()
FieldInfo#loadClassAndGetField()
MethodInfo#loadClassAndGetMethod() and #loadClassAndGetConstructor()
ClassGraph#initializeLoadedClasses()
ClassInfo#getClassLoader()
The public class ClassGraphClassLoader

Load classes yourself, with a classloader you hold:

// ClassGraph 4.x
Class<?> cls = classInfo.loadClass();

// ClassGraph 5.x
Class<?> cls = Class.forName(classInfo.getName(), /* initialize = */ false, myClassLoader);

The natural classloader to hold is the one you scanned with:

try (URLClassLoader classLoader = new URLClassLoader(urls);
        ScanResult scanResult = new ClassGraph().overrideClassLoaders(classLoader)
                .enableAllInfo().scan()) {
    ClassInfo classInfo = scanResult.getClassInfo("com.xyz.Widget");
    Class<?> cls = Class.forName(classInfo.getName(), false, classLoader);
}

From the loaded Class, reflection gives you methods, fields, constructors, enum constants and annotation instances directly, from the JDK, with the JDK's semantics.

Before you write any of that, check whether you needed to load the class at all. Most 4.x code that called loadClass() did so only to reach information that the scan already has:

If you loaded a class in order to... Read it from the scan result instead
read an annotation's parameters AnnotationInfo#getParameterValues() (includes defaults)
read an annotation's parameters as written AnnotationInfo#getDeclaredParameterValues()
read an enum constant in an annotation AnnotationEnumValue#getClassName() / #getValueName()
read a class reference in an annotation AnnotationClassRef#getName()
list an enum's constants ClassInfo#getEnumConstants()
read method or field signatures and modifiers MethodInfo / FieldInfo

ClassInfo#getClassLoaderString() replaces ClassInfo#getClassLoader(). It returns the toString() of the classloader the classfile was found under — an identifier for logging and for working out where a class came from, not a classloader you can load with. It is null if the class was not itself scanned, or was found in a module loaded by the bootstrap classloader.

More generally: no object a 5.x scan produces holds a classloader. Not ScanResult, not ClassInfo, not Resource. The classloaders and module layers you pass to addClassLoader(), overrideClassLoaders(), addModuleLayer() and overrideModuleLayers() are held by the ClassGraph instance itself, so the same instance can still be scanned with more than once, but they are not reachable from the ScanResult. If your code was relying on a ScanResult keeping a classloader alive, hold your own reference to it.

JSON serialization

ScanResult can no longer be serialized to JSON, or read back from it:

Removed in 5.x
ScanResult#toJSON() and #toJSON(int indentWidth)
ScanResult#fromJSON(String json)
ScanResult#isObtainedFromDeserialization()

There is no replacement, and pointing a JSON library at a ScanResult (or at ClassInfo, MethodInfo, FieldInfo and the rest) is not supported either. If you need scan results as JSON, read the values you need out of the ScanResult and generate your own JSON from them — which also gives you a format you control, rather than one that changed whenever ClassGraph's internal fields changed. A serialized 4.x ScanResult could only ever be read back by the same version of ClassGraph that wrote it.

If you were using this to cache a scan across JVM runs, the replacement is to define a small record of just the facts you need, and serialize that.

GraphViz .dot file generation

.dot file generation has moved out of the core artifact, and is no longer reached through ClassInfoList. Add the io.github.classgraph:classgraph-viz dependency, and call the static methods of io.github.classgraph.viz.GraphVizDotFile, which take the ScanResult the classes came from, followed by the classes to graph:

// ClassGraph 4.x
String dot = scanResult.getAllClasses().generateGraphVizDotFile();

// ClassGraph 5.x
String dot = GraphVizDotFile.generate(scanResult, scanResult.getAllClasses());

The eight overloads — the widest of which took two floats and six booleans — are replaced by GraphVizDotFileOptions, whose no-argument constructor holds the defaults and whose methods each switch one option away from its default:

GraphVizDotFile.generate(scanResult, scanResult.getAllClasses(),
        new GraphVizDotFileOptions().layoutSize(12, 8).hideFields().hideMethods());
ClassGraph 4.x (on ClassInfoList) ClassGraph 5.x (on GraphVizDotFile)
generateGraphVizDotFile() generate(scanResult, classes)
generateGraphVizDotFile(float, float) generate(scanResult, classes, options)
generateGraphVizDotFile(float, float, boolean × 5) generate(scanResult, classes, options)
generateGraphVizDotFile(float, float, boolean × 6) generate(scanResult, classes, options)
generateGraphVizDotFile(File) write(scanResult, classes, path)
generateGraphVizDotFileFromInterClassDependencies() generateFromInterClassDependencies(scanResult, classes)
generateGraphVizDotFileFromInterClassDependencies(float, float) generateFromInterClassDependencies(scanResult, classes, options)
generateGraphVizDotFileFromInterClassDependencies(float, float, boolean) generateFromInterClassDependencies(scanResult, classes, options)
generateGraphVizDotFileFromClassDependencies() (deprecated) generateFromInterClassDependencies(scanResult, classes)

The options, with their defaults: layoutSize(10.5f, 8.0f); hideFields(), hideFieldTypeDependencyEdges(), hideMethods(), hideMethodTypeDependencyEdges(), hideAnnotations() and hideAnnotationDependencyEdges() (everything is shown by default, subject to the corresponding ClassGraph#enable*Info() call having been made before the scan); useFullyQualifiedNames() (simple names by default); and includeExternalClasses() / excludeExternalClasses() (by default the inter-class dependency graph follows the scan's own enableExternalClasses() setting).

Four things to check when porting a call:

  • showAnnotations = false maps to hideAnnotationDependencyEdges(), not hideAnnotations(). In 4.x that flag hid only the annotation edges, and there was no way to leave annotations out of the class boxes. hideAnnotations() is the new option that does the latter.
  • write takes a java.nio.file.Path, not a java.io.File, and returns that Path rather than the list it was called on. It writes UTF-8; 4.x wrote the platform default charset, which mangled non-ASCII class and member names on any platform whose default was not UTF-8.
  • An empty list of classes now produces an empty graph, where 4.x threw IllegalStateException("List is empty"). Scanning without enableClassInfo(), or calling generateFromInterClassDependencies without enableInterClassDependencies(), still throws IllegalStateException.
  • The rendering has changed slightly: annotation edges no longer include the shortcut edges that 4.x drew from a class to its annotations' meta-annotations and to its superclasses' @Inherited annotations (those annotations are still in the graph, still reachable along a path of edges); annotations within a class box are now sorted rather than in classfile order; and long annotation lists now wrap instead of making the node very wide. If you compare generated .dot files against stored expected output, regenerate that output.

writeFromInterClassDependencies(scanResult, classes, path) is new — in 4.x the dependency graph could only be generated as a string.

The callback interfaces

ClassGraph's eleven single-method interfaces are gone, replaced by the JDK functional interfaces they duplicated:

Removed interface Now takes
ClassInfoList.ClassInfoFilter Predicate<ClassInfo>
AnnotationInfoList.AnnotationInfoFilter Predicate<AnnotationInfo>
FieldInfoList.FieldInfoFilter Predicate<FieldInfo>
MethodInfoList.MethodInfoFilter Predicate<MethodInfo>
PackageInfoList.PackageInfoFilter Predicate<PackageInfo>
ModuleInfoList.ModuleInfoFilter Predicate<ModuleInfo>
ResourceList.ResourceFilter Predicate<Resource>
ClassGraph.ClasspathElementFilter Predicate<String>
ClassGraph.ClasspathElementURLFilter Predicate<URL>
ClassGraph.ScanResultProcessor Consumer<ScanResult>
ClassGraph.FailureHandler Consumer<Throwable>

A lambda or method reference compiles unchanged — the shape of the argument is the same. What has to change is code that names one of the types (an anonymous class, a field holding a filter, a class that implements one), and the method name inside it:

  • the filters' accept(...) becomes Predicate#test(...)
  • ScanResultProcessor#processScanResult(...) becomes Consumer#accept(...)
  • FailureHandler#onFailure(...) becomes Consumer#accept(...)

In exchange you get the JDK's combinators: filter(p.negate()), filter(p1.and(p2)), filter(p1.or(p2)).

ResourceList's ByteArrayConsumer, InputStreamConsumer and ByteBufferConsumer are kept, because their methods throw IOException and no JDK functional interface does.

Narcissus is now automatic

In 4.x you added Narcissus to your project and selected it in code. In 5.x, adding the dependency is the whole of it — ClassGraph looks for Narcissus when it starts up and uses it if it is present:

Removed in 5.x Use instead
ClassGraph.CircumventEncapsulationMethod (enum) Nothing — just add the Narcissus dependency
ClassGraph#getCircumventEncapsulationMethod() Nothing
ClassGraph#setCircumventEncapsulationMethod(...) Nothing

Delete those calls. If your project sets the 4.x field ClassGraph.CIRCUMVENT_ENCAPSULATION, delete that too.

One thing to be ready for: on JDK 24+, loading Narcissus's native library draws a warning unless the JVM was launched with --enable-native-access=ALL-UNNAMED (JEP 472). Add that flag to your launch command. This is not cosmetic for long — the JEP states that restricted methods will be refused outright in a later release.

Adding Narcissus can change what a scan finds, which is the reason to add it: without it, ClassGraph cannot read the JDK classloaders' private ucp field on JDK 16+, and falls back to the java.class.path system property, which does not cover classpath entries added by a Java agent, boot classpath appends, the parents of a classloader passed to overrideClassLoaders(), or entries a search path holds only in internal bookkeeping.

enableMemoryMapping()

Removed; delete the call. ClassGraph now decides by platform: it memory-maps on Windows, and reads through the file channel on Linux and macOS. There is nothing to configure.

The decision came out of a benchmark across all three platforms, two JDKs, three workloads and both warm and cold page caches. Its conclusions:

  • Windows: mapping is 16% to 38% faster, on both JDKs and all three workloads, with the ranges not even overlapping. This is the largest single effect measured.
  • Linux: 0% to 10% faster warm, a wash cold — and up to 37% slower on a cold page cache when the classpath is mostly resources rather than classfiles, which is what a real Maven repository looks like. A page fault on a mapping fetches the kernel's whole fault-around window, so classfiles scattered among resources that ClassGraph never reads cost extra device traffic: measured device reads went from 31% of the corpus to 50% of it.
  • macOS: inside the noise, in both directions.
  • Mapping also handles errors worse: a jar truncated mid-scan gives a clean end-of-file through a positioned read, but a java.lang.InternalError from a signal handler through a mapping.
  • Two things that sound like reasons were tested and are not: mapping does not make a scanned jar any harder to delete than reading it does (on Windows neither can be deleted while the ScanResult is open, and on Linux and macOS both can), and FileChannel monitor contention is not what mapping is avoiding on Linux — the gap is the same 4% at 1 thread and at 32.

Mapping everywhere would regress the cold, resource-heavy Linux case; mapping nowhere would give up the Windows win; and leaving it as an opt-in asks a question no one can answer without repeating the whole exercise on their own workload. The numbers, the method and the tools are on the Memory mapping benchmark page.

acceptLibOrExtJars() and rejectLibOrExtJars()

Removed; delete the calls. The JRE/JDK extension mechanism they searched was removed from the JDK in JDK 9 (JEP 220), so on every JDK that ClassGraph 5.x supports there are no such jars to match. Relatedly, ClassGraph#enableSystemJarsAndModules() now only affects system modules and packages; it no longer adds any jars to the classpath.

ResourceList#forEach*ThrowingIOException

Each of the three forEach* families had grown three method names and two consumer interfaces. Each is now two methods — one that propagates IOException, one that ignores it:

ClassGraph 4.x ClassGraph 5.x
forEachByteArrayThrowingIOException(ByteArrayConsumerThrowsIOException) forEachByteArray(ByteArrayConsumer)
forEachByteArray(ByteArrayConsumer, boolean) forEachByteArray or forEachByteArrayIgnoringIOException
forEachByteArray(ByteArrayConsumer) forEachByteArray(ByteArrayConsumer), but see the warning below
forEachByteArrayIgnoringIOException(ByteArrayConsumer) unchanged

and likewise for forEachInputStream and forEachByteBuffer. The ByteArrayConsumerThrowsIOException, InputStreamConsumerThrowsIOException and ByteBufferConsumerThrowsIOException interfaces are deleted; ByteArrayConsumer, InputStreamConsumer and ByteBufferConsumer now declare throws IOException on accept.

⚠️ forEachByteArray(consumer) is a silent behavior change — see Step 8.

These six methods now return the ResourceList, rather than void, so they can be chained. Code that ignores the return value is unaffected.

Step 7: Fix the code that no longer compiles

By this point the renames are done, and the compiler will point at everything that is left. The remaining errors will be one of these:

Arrays are now unmodifiable lists

ClassGraph 4.x ClassGraph 5.x
MethodInfo#getParameterInfo()MethodParameterInfo[] List<MethodParameterInfo>
MethodInfo#getThrownExceptionNames()String[] List<String>

Replace array.length with list.size(), array[i] with list.get(i), and for (X x : array) needs no change. MethodInfo#getThrownExceptions() already returned a ClassInfoList and is unchanged, and Resource#load() still returns byte[] (it is file content, not a collection, and each call already returns a fresh array).

Resource#read() returns a CloseableByteBuffer, and readCloseable() is gone

There was one way to read a resource as a buffer and get it back closeable, and another way to read it and have to remember to close the Resource yourself. There is now one way:

// ClassGraph 4.x
ByteBuffer byteBuffer = resource.read();
try {
    // ... use byteBuffer ...
} finally {
    resource.close();
}

// ClassGraph 4.x, the other way
try (CloseableByteBuffer buf = resource.readCloseable()) {
    ByteBuffer byteBuffer = buf.getByteBuffer();
    // ... use byteBuffer ...
}

// ClassGraph 5.x
try (CloseableByteBuffer buf = resource.read()) {
    ByteBuffer byteBuffer = buf.getByteBuffer();
    // ... use byteBuffer ...
}

Delete the readCloseable in a readCloseable() call and it compiles unchanged. A read() call needs .getByteBuffer() adding to reach the buffer, and its result belongs in a try-with-resources. Closing the returned buffer closes the Resource it came from, so a separate resource.close() is no longer needed, though it is still harmless. ResourceList#forEachByteBuffer() is unaffected -- it still hands the consumer a plain ByteBuffer, and still closes it afterwards.

CloseableByteBuffer#close() no longer declares IOException

Nothing in it can throw one. Drop the catch (IOException) around a CloseableByteBuffer used in a try-with-resources: if there is nothing else in the block that can throw IOException, the catch has to go, since catching a checked exception that cannot be thrown does not compile.

Closeable is now AutoCloseable

ScanResult, Resource and CloseableByteBuffer implement java.lang.AutoCloseable rather than java.io.Closeable. None of their close() methods throws IOException, which is the only thing Closeable adds. try-with-resources is unaffected, and so is any call to close(). Only code that names the interface breaks: assigning one of these to a Closeable variable, or passing it to a method that takes a Closeable. Use AutoCloseable instead.

ClassGraphException no longer extends IllegalArgumentException

It extends RuntimeException directly — a failed scan is not a bad argument, and catching IllegalArgumentException around scan() also caught unrelated argument errors. Nothing fails to compile, since both are unchecked, but a catch (IllegalArgumentException e) around scan() no longer catches it. Catch ClassGraphException by name.

Reduced visibility

Several members were public or protected even though their types are internal, so nothing outside ClassGraph could usefully call or override them. They are now package-private. If your code touches any of these, it was reaching into ClassGraph's internals and needs a different approach:

  • ScanResult#reflectionUtils
  • the public Resource(ClasspathElement, long) constructor
  • the protected findReferencedClassInfo(Map, Set, LogNode) methods of ClassInfo, MethodInfo, MethodInfoList, FieldInfo, FieldInfoList, AnnotationInfo, AnnotationInfoList, AnnotationParameterValue, AnnotationParameterValueList, TypeSignature, ClassTypeSignature and MethodTypeSignature
  • the protected addTypeAnnotation methods of HierarchicalTypeSignature, TypeSignature and their subclasses
  • TypeArgument#findReferencedClassNames(Set)
  • the protected fields of ClassInfo (name, typeSignatureStr, isExternalClass, isScannedClass, classfileResource), ClassMemberInfo (declaringClassName, name, modifiers, typeDescriptorStr, typeSignatureStr, annotationInfo), Resource (inputStream, byteBuffer, length) and HierarchicalTypeSignature (typeAnnotationInfo) — every one of these already had a public getter, so use that
  • the protected constructors of ClassInfo, ClassMemberInfo, HierarchicalTypeSignature, TypeSignature, ReferenceTypeSignature, ClassRefOrTypeVariableSignature and TypeParameter, and MethodParameterInfo#setScanResult — these classes only ever come from a scan, so subclassing them outside ClassGraph never worked

Nullability is now declared

The API is annotated @NullMarked: every type in a signature is non-null unless it is explicitly annotated @Nullable. Nothing changes at runtime — this documents behavior that was already the case — but if you use a null-analysis tool that understands JSpecify (IntelliJ IDEA, Eclipse JDT, NullAway, Kotlin), it will now flag calls that dereference a nullable ClassGraph result without checking it, and calls that pass null where ClassGraph does not accept null. Those are pre-existing latent bugs in your code, not new restrictions — fix them rather than suppressing them.

Kotlin callers see ClassGraph types as proper platform-free types: a @Nullable return is T?, and everything else is T rather than T!. Kotlin code that was written against T! may now fail to compile where it was implicitly treating a @Nullable return as non-null; that is the same latent bug, made visible.

You do not need to add a JSpecify dependency: ClassGraph declares it in provided scope, so it is not a transitive dependency of your project, and the JVM ignores annotations whose type is absent. Add org.jspecify:jspecify to your own build only if you want your tools to read the annotations.

Step 8: Review the changes that compile but behave differently

This is the step the compiler cannot do for you. Each item below names what to search for.

getSuperclass() now returns Object rather than null

4.x hid java.lang.Object from the class graph. 5.x does not, which changes four things:

  • ClassInfo#getSuperclass() now returns Object for a standard class that extends no other class, instead of null. As with Class#getSuperclass(), null is now returned only for Object itself and for interfaces.
  • ClassInfo#getAllSuperclasses() and ScanResult#getAllSuperclasses(...) now end with Object, if the whole superclass chain was scanned. In 4.x Object was always excluded.
  • If java.lang.Object is accepted, it is now scanned like any other class, so it appears in getAllClasses() and its members can be read. It also appears in getAllClasses() and getAllStandardClasses() whenever enableExternalClasses() is called.
  • getAllSubclasses("java.lang.Object") still returns every standard class in the scan result, as before.

Search for: getSuperclass, and any loop that walks up a superclass chain. A loop that runs until getSuperclass() returns null now takes one more step, and a null check that read as "this class extends nothing" now needs to compare the name against "java.lang.Object":

// ClassGraph 4.x
for (ClassInfo ci = classInfo; ci != null; ci = ci.getSuperclass()) { ... }

// ClassGraph 5.x — if you do not want to visit Object
for (ClassInfo ci = classInfo; ci != null && !ci.getName().equals("java.lang.Object");
        ci = ci.getSuperclass()) { ... }

Rendered output is unchanged: ClassInfo#toString() and the type signature classes still leave out an extends java.lang.Object clause, and neither the .dot file nor getClassDependencies() includes Object.

There is now one glob syntax, used everywhere

4.x had three different glob dialects, and which one you got depended on which method you called. There is now one, shared by every accept/reject criterion and by ScanResult#getResourcesMatchingWildcard():

Wildcard Matches
* zero or more characters within one package or path segment
** zero or more whole segments — must form a complete segment on its own
? exactly one character, other than the separator

Every other character is matched literally, including regex metacharacters.

Package and path globs do not change. acceptPackages, rejectPackages, acceptPaths and rejectPaths have used exactly these rules since ClassGraph 4.8.187, so if you are coming from 4.8.187 or later, their arguments port unchanged. (If you are coming from 4.8.185 or earlier, * used to cross . and / there too, so read the table below for those as well.)

What does change is every other glob. Search for: each string literal passed to acceptClasses, rejectClasses, acceptModules, rejectModules, acceptJars, rejectJars, acceptClasspathElementsContainingResourcePath, rejectClasspathElementsContainingResourcePath and getResourcesMatchingWildcard. A glob with no wildcard in it, or with a * that was only ever meant to match within one segment, needs no change. Otherwise:

What you wrote in 4.x What to write in 5.x Why
acceptClasses("*.*Suffix") acceptClasses("**.*Suffix") * no longer crosses .
acceptModules("java.*") acceptModules("java.**") same — "java.*" now matches java.base but not java.xml.crypto
acceptClasspathElementsContainingResourcePath("META-INF/*") ...("META-INF/**") same, for /
getResourcesMatchingWildcard("**.txt") getResourcesMatchingWildcard("**/*.txt") ** must be a whole segment; the old form now throws IllegalArgumentException
getResourcesMatchingWildcard("[abc].txt") getResourcesMatchingPattern(Pattern) regex syntax is no longer passed through to the regex engine
a jar or resource glob containing ? getResourcesMatchingPattern(Pattern), or escape it ? was matched literally by the class/jar/module globs, and now matches one character

Two 4.x behaviors change in the direction of matching more, so a glob that used to match nothing may now start matching. Both are bug fixes, and both are also fixed in the final 4.x release:

  • A class, jar or module glob containing a regex metacharacter other than . had that character copied into the pattern unescaped, so it was interpreted as regex syntax — the $ in the binary name of a nested class acted as an end-of-input anchor, and acceptClasses("com.Outer$Inner*") matched nothing.
  • getResourcesMatchingWildcard("**/*.txt") required at least one directory level, because ** did not absorb the separator after it. It now also matches a .txt resource at the classpath root.

forEach* now throws IOException

Search for: forEachByteArray, forEachInputStream, forEachByteBuffer.

4.x code that calls forEachByteArray(consumer) from a method that already declares throws IOException still compiles, but where it used to see an IllegalArgumentException wrapping the cause, it now sees the IOException itself. Code that catches IllegalArgumentException around a forEach* call must catch IOException instead. Everywhere else, the compiler will point at the call, because IOException is checked.

Downward queries no longer return external classes

An external class is one that was reached during a scan without being accepted itself — typically the superclass or an interface of an accepted class. 4.x decided whether to report external classes based on whether the class the query started from was itself external, so the same query could include or exclude them depending on where in the hierarchy it was asked. The rule in 5.x:

  • A query that looks upwards — for superclasses, interfaces, annotations, meta-annotations or outer classes — includes external classes, as in 4.x. It is reporting what an accepted classfile itself declares.
  • A query that looks downwardsgetAllSubclasses(), getDirectSubclasses(), getAllClassesImplementing(), getDirectClassesImplementing(), getAllSubinterfaces(), getDirectSubinterfaces(), getClassesWithAnnotation(), getClassesWithMethodAnnotation(), getClassesWithFieldAnnotation() and the rest of that family — returns only accepted classes.

Only the downward queries change. If you were relying on external classes coming back from one, call ClassGraph#enableExternalClasses(), which returns them along with all the other external classes the scan reached.

Classes only reachable through an external class are still found: if an accepted class extends an external class that implements I, the accepted class is still returned by getClassesImplementing(I). (4.x could drop these, because it filtered partway through the traversal rather than at the end.)

Relatedly, external classes are no longer listed as members of their package or module. PackageInfo#getClassInfo(), PackageInfo#getClassInfoRecursive() and ModuleInfo#getClassInfo() now list only the classes that getAllClasses() returns; a package or module containing nothing but external classes no longer appears in ScanResult#getPackageInfo() or #getModuleInfo(); and ClassInfo#getPackageInfo() and #getModuleInfo() return null for an external class. Again, enableExternalClasses() restores the 4.x listing.

Inherited members from JDK supertypes are now reported

In 4.x, the class hierarchy above an accepted class stopped as soon as it reached a JDK class, because system modules are not scanned unless asked for. A class extending java.util.TimerTask did not report Runnable among its interfaces, and its superclass chain ended at TimerTask. In 5.x the classfile is read from its module even though that module is not being scanned.

What this changes for you: with enableMethodInfo(), enableFieldInfo() or enableAnnotationInfo(), the queries that include inherited members — getMethodInfo(), getFieldInfo(), getAnnotationInfo() and their variants — now report the members a class inherits from JDK supertypes. An annotation type, for example, now reports annotationType(), equals(), hashCode() and toString(), inherited from java.lang.annotation.Annotation. The getDeclared... queries are unaffected, so if you want only what the class itself declares, use those.

No JDK class, package or module is added to the scan result by this: the classes read this way are external classes, so they do not appear in getAllClasses() unless enableExternalClasses() is called. java.lang.Object is still never read, so its methods and fields are never reported.

Only reject criteria stop a classfile being read this way: a module excluded with rejectModules() is never read from, but a module that simply was not accepted can still have individual classfiles read out of it. This is the rule that already applied to classes. And nothing changes at all where ClassGraph does not enumerate modules — after disableModuleScanning(), or after overrideClasspath() or overrideClassLoaders() without the application classloader — unless a module is asked for by name, or enableSystemJarsAndModules() is called.

Collections returned by the API are now unmodifiable

Every List and Map handed back by the public API is unmodifiable. In 4.x some were and some were not, with no way to tell from the signature — and several of the modifiable ones were ClassGraph's own internal lists, so writing to a returned list silently corrupted the scan result.

add, remove, set, clear, sort, removeIf, replaceAll, put and the rest now throw UnsupportedOperationException, as do the iterators, list iterators and sublists obtained from them. This includes derived lists (filter(), union(), intersect(), exclude(), directOnly()), the plain List returns such as InfoList#getNames() and ResourceList#getPaths(), and the lists nested inside returned maps such as MethodInfoList#asMap() and ResourceList#asMap().

Search for: any call to a mutating method on a value that came from ClassGraph, and any Collections.sort(...) over one. Copy first: new ArrayList<>(list), new HashMap<>(map). Lists you construct yourself, through a public constructor such as new ClassInfoList(collection), are still modifiable.

Note that a mutation which would not have changed the list — clear() on an empty one, addAll(List.of()), retainAll() with a collection that already holds everything — threw in some 4.x cases and silently did nothing in others. All of them now throw.

Empty collections are now List.of() / Set.of() rather than Collections.emptyList(). Both are immutable and empty, but List.of() rejects a null argument to contains(), indexOf() and lastIndexOf() with NullPointerException, where Collections.emptyList() answered false / -1.

Null arguments now throw NullPointerException

Every public API method that does not accept null now throws NullPointerException if it is passed one, with a message naming the parameter, e.g. packageNames[1] must not be null.

This matters most where 4.x accepted the null and carried on. About 25 methods silently ignored a null argument or returned a "not found" answer for it:

  • ClassGraph#acceptPackages, #rejectPackages, #acceptClasses, #acceptPaths, #acceptJars, #acceptModules and the rest of the accept/reject family dropped a null varargs element and scanned with the remaining criteria — so a null in a list of package names quietly widened the scan. This is the one most likely to be hiding in a program that builds its criteria at runtime.
  • ClassGraph#addClassLoader(null) and #addModuleLayer(null) were ignored.
  • ScanResult#getClassInfo, #getPackageInfo, #getModuleInfo, #getResourcesWithLeafName, ClassInfo#getFieldInfo(String), #getMethodInfo(String), ClassInfoList#get(String), MethodInfoList#get(String), ResourceList#get(String), PackageInfo#getClassInfo(String) and ModuleInfo#getClassInfo(String) returned null for a null name.
  • ClassInfo#hasAnnotation(String), #hasDeclaredMethod(String), ClassInfoList#containsName(String) and the other has*/contains* queries returned false for a null name.

Search for: any ClassGraph call whose argument can be null at runtime — a value read from configuration, a map lookup, an optional field. Code that relied on passing null to mean "no filter" has to stop passing it. Code that was already passing non-null arguments is unaffected.

Comparison methods are the exception: equals(Object) and TypeSignature#equalsIgnoringTypeParams(TypeSignature) still answer null with false.

Misuse now throws IllegalStateException or UnsupportedOperationException

4.x threw IllegalArgumentException for almost every kind of misuse, including many with no argument involved. Search for: catch (IllegalArgumentException anywhere near a ClassGraph call.

  • IllegalStateException when the failure depends on the state of the receiver: every "Please call ClassGraph#enableXInfo() before #scan()" guard; using a ScanResult after it has been closed; asking a class for something it is not (getEnumConstants() on a non-enum, getAnnotationDefaultParameterValues() on a non-annotation); a classpath element, resource or module whose URI or URL cannot be determined; and TypeVariableSignature#resolve() when the declaring class was not found during the scan.
  • UnsupportedOperationException when the receiver does not support the operation at all: every mutating method of an unmodifiable InfoList; getClassName() and getClassInfo() on the objects that do not stand for a class (AnnotationClassRef, AnnotationParameterValue, TypeArgument, TypeParameter, ClassTypeSignature, MethodTypeSignature).

IllegalArgumentException is now thrown only where an argument is present but invalid — a malformed glob, or a type signature string that does not parse.

⚠️ PackageInfo and ModuleInfo are new to the "Please call enableAnnotationInfo()" guard. In 4.x, asking a package or module for its annotations without having enabled annotation info returned an empty list, so a forgotten call looked like a package with no annotations. It now throws. If your code reads package or module annotations, make sure enableAnnotationInfo() (or enableAllInfo()) is called before scan().

toString() output has changed

If you compare toString() output against stored strings, or parse it, regenerate your expected values. ClassGraph's toString() methods are meant to read as Java source declarations, and several had drifted from the corresponding JDK method:

  • A constructor is named after the class it constructs, not <init>. MethodInfo#toString() prints public com.xyz.Widget(java.lang.String a) rather than public <init>(java.lang.String a). MethodInfo#getName() still returns "<init>". Static initializers are still <clinit>.

  • A variadic parameter is shown as T... in MethodParameterInfo#toString(), rather than as its declared array type. (MethodParameterInfo#isVarArgs() and #getIndex() are new.)

  • A TYPE_USE annotation on a parameter is listed once by MethodParameterInfo#toString(); it used to be printed twice.

  • Annotation parameter values are rendered in the Java source syntax for their type, as Annotation#toString() renders them:

    Value 4.x 5.x
    String[] {a, b} {"a", "b"}
    char[] {a, b} {'a', 'b'}
    byte 1 (byte)0x01
    long 4 4L
    float 1.5 1.5f
    Float.NaN NaN 0.0f/0.0f
    Double.POSITIVE_INFINITY Infinity 1.0/0.0

    String and character escaping now covers everything the JDK escapes, rather than just the quote characters, \n and \r.

  • A field's constant initializer value is escaped the same way, in FieldInfo#toString().

  • PackageInfo#toString() and ModuleInfo#toString() name what they arepackage com.xyz and module java.base, rather than the bare name. Call getName() for the name alone.

An annotation reachable more than one way is reported from the most direct way

getAllAnnotationInfo(annotationName) looks the name up in the list returned by getAllAnnotationInfo(), which holds the annotations directly present on a class or member, the ones inherited from a superclass, and the meta-annotations of both. In 4.x that list was sorted by name and then by parameter value, so when the same annotation appeared more than once, which one you got came down to alphabetical order of the parameter values.

The list is now sorted by name, then by how directly each annotation is related: directly present first, then inherited, then reached as a meta-annotation. So getAllAnnotationInfo(name) returns the annotation on the class or member itself whenever there is one. An annotation reached twice by the same route is now listed once rather than twice.

AnnotationParameterValue#getValue() returns the stored array

For an array of a reference type (String[], Class[], arrays of enum constants or nested annotations), 4.x rebuilt a fresh array on every call; arrays of primitive types were returned by reference. Both are now returned by reference. Writing to the returned array now changes what later calls return — copy it first if you need to modify it.

Other behavior changes worth a look

  • enableURLScheme() now rejects anything that is not a URL scheme. 4.x only checked that the argument was at least two characters, so enableURLScheme("s3:") — written with its colon, an easy mistake — was accepted and then never matched anything. The argument must now be a scheme name as defined by RFC 3986, and IllegalArgumentException is thrown if it is not.
  • A system module accepted by name is now scanned without enableSystemJarsAndModules(). In 4.x, acceptModules("jdk.compiler") found nothing unless enableSystemJarsAndModules() was also called. If your code calls both, you can drop the second one — it now means "scan all system modules", which is more than you asked for.
  • The JDK's application and platform classloaders are now mapped to the scanning mechanism that can reach their classes, for addClassLoader() as well as overrideClassLoaders(). Passing the application classloader now also scans the application's own modules (in 4.x it found nothing at all in an application launched on the module path); passing the platform classloader now scans the system modules rather than the application classpath.
  • Classloader delegation order is now respected. In 4.x every classloader was placed in the classpath ahead of the classloaders it delegates to, whatever its real delegation order, so for a parent-first classloader the classpath came back in the reverse of the order the JVM resolves classes in. This affects the order of ScanResult#getClasspath() and friends, and which copy of a class defined in more than one classpath element is reported. It does not change anything for the JDK's own classloaders in a normal application.
  • A jar or directory reachable both as a module and as a classpath entry is now listed once, as the module. This is a bug fix — the methods were always documented to return the unique classpath elements — and it does not change which classes or resources a scan returns.
  • Reading from a Resource's InputStream after closing it now fails with IOException: Stream closed rather than NullPointerException.
  • Malformed classfiles are now reported rather than silently producing null names. Where 4.x carried a null through into the scan result — producing entries whose name was null or a string containing "null" — 5.x throws ClassfileFormatException, which the scanner catches per classfile: the classfile is logged as invalid and skipped, and the rest of the scan proceeds. Valid classfiles are unaffected.
  • ClassGraph no longer runs its reflective calls inside AccessController#doPrivileged. This only affects you if you run on JDK 17–23, explicitly opt in with -Djava.security.manager=allow, and grant ClassGraph's jar more permissions than the calling code — in which case, grant the calling code the same permissions instead. The Security Manager is permanently disabled in JDK 24 (JEP 486).
  • ModuleInfo#getPackageInfo() is now unmodifiable for a module with no packages too. It used to return a fresh modifiable empty list in that one case, and an unmodifiable one otherwise. Adding to the returned list never affected the scan result, so the only change is that it now throws UnsupportedOperationException.
  • Method annotations now wrap in the class graph .dot file. In the node for a class, each method is a row of three cells — its annotations, its name, and its parameters — and only the parameter cell wrapped, so a class with heavily annotated methods produced a node many times wider than it needed to be. The annotation cell now wraps at the same width. The same annotations, methods and parameters are listed; only the layout changes.
  • getURL() now works for system modules and jlink'd runtime images. Resource#getURL(), Resource#getClasspathElementURL(), ClassInfo#getClasspathElementURL() and ResourceList#getURLs() were documented to throw IllegalStateException for a jrt: location. That has not been true since JDK 9, and the documented exception is gone. If you wrote a fallback path for it, you can delete it.

Step 9: Rebuild and test

  1. Build the project. Fix any remaining compile errors using the tables above.
  2. Run your test suite.
  3. If any test compares against stored expected output — .dot files, toString() output, ordered classpath listings, lists of subclasses or annotations — check the failures against Step 8 before regenerating the expected values, so that you are accepting an intended change rather than papering over a real one.
  4. Turn on ClassGraph#verbose() for one run and read the log. It reports the classpath elements in the order they will be scanned, which is the quickest way to see whether the scan is finding what it did before.

Optional: adopt the new capabilities

None of these are needed to port, but they are the reason for several of the changes above.

Find the classpath without scanning

classgraph-classpath is now usable on its own, without pulling in the scanner. It reports the classpath elements in the order the classloaders would search them, the modules split into system and non-system, and the module path switches the JVM was launched with:

try (Classpath classpath = new ClasspathFinder().find()) {
    for (ClasspathEntry entry : classpath) {
        System.out.println(entry.location());
    }
}

ClasspathFinder has the same classloader, module layer and classpath override methods as ClassGraph (overrideClasspath, overrideClassLoaders, addClassLoader, ignoreParentClassLoaders, overrideModuleLayers, addModuleLayer, ignoreModules, verbose), and finds classpath elements from the same custom container classloaders — Spring Boot, Tomcat, JBoss, WebLogic, Quarkus and the rest.

The classpath it returns is the full, expanded classpath: the jarfiles in a classpath element's automatic lib dirs (BOOT-INF/lib/ and WEB-INF/lib/ everywhere, plus the ones the classloader it came from loads from, such as lib/ for Tomcat), and the entries of its manifest's Class-Path and Bundle-ClassPath attributes, followed recursively, each reported directly after the element that declared it. A directory is expanded the same way a jarfile is, so an exploded jarfile declares the same children the jarfile it was exploded from did, and a child named by a relative path is resolved beside the element that declared it, in whatever FileSystem that element lives in.

Reading those manifests means opening the classpath elements, so Classpath is AutoCloseable; the entries can still be read after it has been closed. While it is open, those elements are open too, so Classpath#getVfs() hands back the virtual filesystem they were read through, so ClasspathEntry#open(Vfs) reads a classpath element without opening it a second time.

Note that it reports where classes and resources would be loaded from, so an element that is named but is not there is still reported, and a nested jar is reported in the outer.jar!/inner.jar form rather than being extracted. ClassGraph#getClasspathFiles() and friends still drop the elements that are not there and strip package roots, so they remain the ones to use when you need real files.

Read directories, jarfiles and modules without scanning

classgraph-vfs is a read-only virtual filesystem: the storage layer ClassGraph scans through, now usable on its own. It reads a directory, a jarfile or a module through one interface, however the thing was named, and it reads a jarfile nested inside another jarfile — as produced by Spring Boot and other executable-jar formats — without extracting it to a temporary directory first:

try (Vfs vfs = new Vfs();
        VfsRoot root = vfs.open("outer.jar!/lib/inner.jar")) {
    for (VfsEntry entry : root) {
        System.out.println(entry.getName() + " (" + entry.getLength() + " bytes)");
    }
}

Vfs#open accepts a path string, a File, a Path in any filesystem provider, a URI, a URL, a ModuleReference, an InputStream, or a byte[], and returns a VfsRoot in every case. Nested jarfiles are named by separating each jarfile from the one that encloses it with "!/", to any depth; a trailing "!/" section naming a directory rather than a jarfile is used as the package root ("spring-boot-app.jar!/BOOT-INF/classes"), so entry names are reported relative to it. An entry's content is read with VfsEntry#open(), #openChannel(), #read(), #load() or #loadAsString(), and VfsRoot#asFileSystem() presents any root as a read-only java.nio.file.FileSystem, so a library that takes a Path can read a nested jarfile or a module without knowing that is what it has.

Directory entries, encrypted entries and entries stored with an unsupported compression method are not reported, and for a multi-release jarfile only the newest version of each entry the running JVM can use is reported, unless the Vfs was constructed with multi-release versions enabled. The Vfs owns the file handles, memory mappings and temporary files behind everything it opened, so it must be closed, and it must stay open for as long as its entries are being read. It is also safe to use from many threads at once, unlike java.util.zip.ZipFile, which serializes every public method on the instance monitor.

The scanner and the virtual filesystem are not separate worlds. Resource#getVfsEntry() returns the VfsEntry that a scanned resource is read from, so everything above is available for a resource the scanner found — reading it as a channel, addressing it as a Path, asking for its compressed size — without opening anything a second time.

See the Vfs API for the full reference.

Smaller additions

  • ScanResult#isClassInfoEnabled(), #isFieldInfoEnabled(), #isMethodInfoEnabled(), #isAnnotationInfoEnabled(), #isInterClassDependenciesEnabled(), #isExternalClassesEnabled(), #isFieldVisibilityIgnored() and #isMethodVisibilityIgnored() — so you can ask a ScanResult what the scan was configured to collect, rather than having to pass that alongside it.
  • ClassMemberInfo#getClassDependencies() (inherited by FieldInfo and MethodInfo) — the classes referred to by a single field or method.
  • ClassInfoList#getAssignableTo(Class<?>) and #getAssignableTo(String), so you no longer have to look the ClassInfo up first and handle the case where it was not found.
  • ScanResult#getAllAnnotationsOnClass(Class) and #getDirectAnnotationsOnClass(Class), matching the Class<?> / String overload pair the rest of the query API offers.
  • MethodParameterInfo#isVarArgs() and #getIndex().
  • PackageInfo and ModuleInfo now have the full twelve-method annotation surface, including getAllAnnotationInfoRepeatable(...) and getDirectAnnotationInfoRepeatable(...).

Appendix A: internal package moves

ClassGraph's internal classes used to live under a top-level nonapi package. Each module's internals now sit under that module's own package, in a package named internal, and are exported only to the ClassGraph modules above them:

Was Is now
nonapi.io.github.classgraph.utils io.github.classgraph.base.internal.utils
nonapi.io.github.classgraph.reflection io.github.classgraph.base.internal.reflection
nonapi.io.github.classgraph.concurrency io.github.classgraph.base.internal.concurrency
nonapi.io.github.classgraph.recycler io.github.classgraph.base.internal.recycler
nonapi.io.github.classgraph.fastzipfilereader io.github.classgraph.vfs.internal.zip
nonapi.io.github.classgraph.fileslice io.github.classgraph.vfs.internal.slice
nonapi.io.github.classgraph.fileslice.reader io.github.classgraph.vfs.internal.slice.reader
nonapi.io.github.classgraph.classpath io.github.classgraph.classpath.internal
nonapi.io.github.classgraph.classloaderhandler io.github.classgraph.classpath.internal.classloaderhandler
nonapi.io.github.classgraph.types io.github.classgraph.internal.types
nonapi.io.github.classgraph.json (deleted — see JSON serialization)

Three classes did not follow the row their old package maps to. Parser and ParseException are generic parsing machinery rather than anything to do with Java type signatures, so they are in io.github.classgraph.base.internal.parser; only TypeUtils is left in io.github.classgraph.internal.types. CallStackReader is a StackWalker wrapper rather than anything to do with finding a classpath, so it is in io.github.classgraph.base.internal.reflection.

nonapi.io.github.classgraph.scanspec held the settings of a scan, which are now split across the modules that read them: ScanSpec is in io.github.classgraph.internal.scanspec, the classpath and classloader settings are in io.github.classgraph.classpath.internal.spec, the jarfile-reading settings are in io.github.classgraph.vfs.internal.spec, and AcceptReject — which is glob matching rather than a setting — is in io.github.classgraph.base.internal.utils.

Nothing in the public API refers to these packages, so this only affects code that reached into ClassGraph's internals, and OSGi or JPMS configuration that names them. If you were depending on an internal class, say so in an issue — it usually means something is missing from the public API.

Note also that Classpath is spelled with a lowercase p throughout the new classpath API — Classpath, ClasspathEntry, ClasspathFinder — matching ClassGraph#getClasspath(), #overrideClasspath() and the other long-standing method names.

Appendix B: the ClassGraph 4.x documentation

The wiki documents the 5.x API. To read the 4.x documentation:

git clone https://github.com/classgraph/classgraph.wiki.git
cd classgraph.wiki
git checkout v4

The 4.x source remains on the v4 branch of the main repository. The last 4.x release is 4.8.190, and its Javadoc, along with that of every earlier version, remains on javadoc.io.

The v4 branch stays open for backwards-compatibility fixes, so 4.x code that cannot be ported yet is not stranded.

Clone this wiki locally