Lesson 03 / 12
Multi-Module Projects
Of three boundary setups, one is caught at compile time, one at run time, one crosses the boundary silently without ever being caught. The limiting measurement shows why: Public and Internal are compiled into the same directory, and what actually builds the boundary is not the directory but a loader's allow list — of two class files in the same directory, one can be loaded while the other is rejected with a ClassNotFoundException.
Published
In this lesson
- Measures the compile-time counterpart of a module boundary with three separate setups, counting how many are caught at compile time and how many at run time
- Shows that two types compiled from the same directory can be given separate access levels by being placed on different class paths
- Tests that a class loader's allow list can load a class from a directory while rejecting another class that is physically in the same directory
Contents
The previous lesson measured which class path each of six standalone types is required on — but every type stood on its own, staying within the boundary of a single class or interface. A real project is not built from single-file classes, it is built from interdependent modules, and the line between the surface a module considers “open to others” and the surface it keeps “to itself” is not a language rule, it is a compile decision. This lesson measures the compile-time counterpart of that line: what happens when a module boundary is crossed, and where does that catch come from?
The module boundary here is built in its smallest possible form for a multi-module project:
two sides, one “providing,” one “consuming.” The providing side has a public surface (Public)
and an internal implementation (Internal) within itself; the consuming side should only use
the public surface and never touch the internal implementation at all. In a real multi-module
project this separation usually shows up as separate directories, separate compile outputs —
but the compile itself never says why it enforces this separation. This lesson extracts
that reason by measuring it.
The Compile-Time Counterpart of a Module Boundary
A module’s public surface and its internal implementation can be compiled from the same source
directory, with the same compile call, into the same output directory — Java itself does not
separate them. The Public and Internal classes below are exactly like this: both are
written, in a single compile call, into a single output directory (allOutput). Public
represents the side considered open to the outside, Internal represents the side that should
stay only within the module itself — but so far this separation is only a naming decision, the
compile itself is not yet enforcing anything at all.
- BT14. All three boundary setups are measured through the same internal type
(
Internal.secret); what differs is only the access style (direct reference / reflection) and which directory is put on the runtime class path. - BT15.
PublicandInternalare compiled from the same source directory, with the same compile call, into the same output directory; there is no physical separation between them at all.
The step that makes the boundary “real” is copying a subset of the output into a separate
directory: only Public.class is copied into a chosen directory named publicOnly. The class
path given to a module’s consumer points at this chosen directory, not at the full output where
Internal also lives.
// ModuleHelper.java — shared operations for compiling a source and running it against a class path list
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;
public class ModuleHelper {
static Path write(Path dir, String name, String source) throws IOException {
Files.createDirectories(dir);
Path p = dir.resolve(name + ".java");
Files.writeString(p, source);
return p;
}
static boolean compile(Path output, List<Path> classPath, Path... sources) throws IOException {
Files.createDirectories(output);
List<String> arg = new ArrayList<>(List.of("-d", output.toString()));
if (!classPath.isEmpty()) {
StringJoiner sj = new StringJoiner(File.pathSeparator);
for (Path p : classPath) sj.add(p.toString());
arg.add("-classpath");
arg.add(sj.toString());
}
for (Path s : sources) arg.add(s.toString());
ToolProvider compiler = ToolProvider.findFirst("javac").orElseThrow();
StringWriter silent = new StringWriter();
int result = compiler.run(new PrintWriter(silent), new PrintWriter(silent), arg.toArray(new String[0]));
return result == 0;
}
static String run(List<Path> classPath, String mainClass) throws Exception {
URL[] url = new URL[classPath.size()];
for (int i = 0; i < url.length; i++) url[i] = classPath.get(i).toUri().toURL();
try (URLClassLoader loader = new URLClassLoader(url, ModuleHelper.class.getClassLoader().getParent())) {
Class<?> cls = Class.forName(mainClass, true, loader);
cls.getMethod("main", String[].class).invoke(null, (Object) new String[0]);
return "ok";
} catch (java.lang.reflect.InvocationTargetException e) {
return e.getCause().getClass().getSimpleName();
} catch (Throwable t) {
return t.getClass().getSimpleName();
}
}
}
compile does the same operation as in the previous lessons: it compiles sources against a
given class path. run runs the compiled class against a separate class path and returns the
name of the exception class thrown — if it is not “ok,” that means the boundary was caught at
run time.
Outside these two methods, ModuleHelper knows nothing at all — it recognizes neither the
concept of “public” nor “internal,” nor what a module is. The boundary itself lives outside
this class, in the decision the calling code makes about which directories go onto which class
path. This foreshadows this lesson’s core observation: a module boundary is not a language
feature, it is a usage decision.
Three Boundary Setups
Internal.secret represents a module’s internal implementation: calling it from the outside is
“crossing the boundary.” The three setups below try this crossing in three different ways; all
of them use the same Internal source; the first two use the publicOnly class path trap, the
third uses allOutput instead — but one accesses it by writing a type name in the source text,
two by giving a string.
- BT16. The “not caught” result is not just a reference compared against setup 1’s compile failure; in setup 3 the program runs without error and the internal type’s value is actually read.
- BT17. The restricted loader (
RestrictedLoader) uses the same principle as the custom loader mechanism built in the Class Loading lesson of the JVM, Concurrency and Performance course; it only adds an allow list to its own declaration. - BT18. The allow list looks at the class name, not the directory; of two class files in
the same directory, one can be loaded while the other is rejected with a
ClassNotFoundException. - BT19. The
compute()method never mentions theInternaltype at all in the restricted loader’s measurement; this is whyPublicloading and running is measured independently of whetherInternalis reachable or not.
// RestrictedLoader.java — a loader that, from class files in the same directory, only defines allowed names
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
class RestrictedLoader extends ClassLoader {
private final Path dir;
private final Set<String> allowed;
RestrictedLoader(Path dir, Set<String> allowed) {
super(RestrictedLoader.class.getClassLoader().getParent());
this.dir = dir;
this.allowed = allowed;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
if (!allowed.contains(name)) throw new ClassNotFoundException(name);
try {
byte[] bytes = Files.readAllBytes(dir.resolve(name + ".class"));
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
}
// ModuleBoundary.java — the compile-time counterpart of a module boundary: the boundary is a class path and loader decision
import java.io.*;
import java.nio.file.*;
import java.util.*;
public class ModuleBoundary {
public static void main(String[] args) throws Exception {
Path root = Files.createTempDirectory("module");
Path source = root.resolve("source");
Path sInternal = ModuleHelper.write(source, "Internal",
"public class Internal { public static int secret() { return 99; } }");
Path sPublic = ModuleHelper.write(source, "Public",
"public class Public { public static int compute() { return 41; } }");
Path allOutput = root.resolve("allOutput");
ModuleHelper.compile(allOutput, List.of(), sInternal, sPublic);
Path publicOnly = root.resolve("publicOnly");
Files.createDirectories(publicOnly);
Files.copy(allOutput.resolve("Public.class"), publicOnly.resolve("Public.class"));
System.out.println("-- three boundary setups, how many are caught at compile time, how many at run time --");
Path sB1 = ModuleHelper.write(root.resolve("b1"), "B1",
"public class B1 { public static void main(String[] a) { System.out.println(Internal.secret()); } }");
boolean b1Compiled = ModuleHelper.compile(root.resolve("b1Output"), List.of(publicOnly), sB1);
System.out.println("setup 1 - direct reference, internal dir not on the class path: compiled = " + b1Compiled
+ " -> " + (b1Compiled ? "not caught" : "caught at compile time"));
Path sB2 = ModuleHelper.write(root.resolve("b2"), "B2",
"public class B2 { public static void main(String[] a) throws Exception {\n"
+ " Class<?> c = Class.forName(\"Internal\");\n"
+ " c.getMethod(\"secret\").invoke(null); } }");
Path b2Output = root.resolve("b2Output");
boolean b2Compiled = ModuleHelper.compile(b2Output, List.of(publicOnly), sB2);
System.out.println("setup 2 - access via reflection, internal dir not on the class path: compiled = " + b2Compiled);
String setup2Run = ModuleHelper.run(List.of(b2Output, publicOnly), "B2");
System.out.println("setup 2 run result: " + setup2Run
+ " -> " + (setup2Run.equals("ok") ? "not caught" : "caught at run time"));
String setup3Run = ModuleHelper.run(List.of(b2Output, allOutput), "B2");
System.out.println("setup 3 - same reflective class, the full internal dir added to the runtime class path: result = "
+ setup3Run + " -> " + (setup3Run.equals("ok") ? "not caught at all, boundary silently crossed" : "caught"));
System.out.println();
System.out.println("-- limiting measurement: the boundary is not a directory separation --");
System.out.println("Public and Internal are in the same directory (allOutput): " + Files.exists(allOutput.resolve("Public.class"))
+ ", " + Files.exists(allOutput.resolve("Internal.class")));
RestrictedLoader restricted = new RestrictedLoader(allOutput, Set.of("Public"));
Class<?> publicClass = Class.forName("Public", true, restricted);
System.out.println("did the restricted loader load Public: true, compute() = "
+ publicClass.getMethod("compute").invoke(null));
String internalResult;
try {
Class.forName("Internal", true, restricted);
internalResult = "loaded";
} catch (ClassNotFoundException e) {
internalResult = "ClassNotFoundException";
}
System.out.println("could the same restricted loader load Internal (despite being in the same dir): " + internalResult);
}
}
-- three boundary setups, how many are caught at compile time, how many at run time -- setup 1 - direct reference, internal dir not on the class path: compiled = false -> caught at compile time setup 2 - access via reflection, internal dir not on the class path: compiled = true setup 2 run result: ClassNotFoundException -> caught at run time setup 3 - same reflective class, the full internal dir added to the runtime class path: result = ok -> not caught at all, boundary silently crossed -- limiting measurement: the boundary is not a directory separation -- Public and Internal are in the same directory (allOutput): true, true did the restricted loader load Public: true, compute() = 41 could the same restricted loader load Internal (despite being in the same dir): ClassNotFoundException
The first setup measures the boundary in its strictest form: B1 calls Internal.secret
through a direct type reference, and Internal is not on the class path at all. The compiler
cannot resolve the symbol; the compile collapses — the boundary is caught at compile time.
The second setup makes the same call through reflection: the name Internal never appears
directly anywhere in B2’s source, so the compile finishes without a problem. But the runtime
class path does not contain Internal either, and Class.forName throws a
ClassNotFoundException at run time — this time the boundary is caught at run time.
The third setup runs the same compiled B2 class unchanged, only adding a different directory
to the runtime class path: allOutput — the module’s entire internal output — instead of
publicOnly. This is a common mistake in a real project: someone puts the entire compile
output, not just “the public interface alone,” on the runtime class path. Result: the program
runs without error, Internal is found and called — the boundary is not caught at all, it
is silently crossed.
Of three boundary setups, one is caught at compile time, one at run time, one is never
caught at all. What determines which catch happens is not how B1/B2’s source is written,
it is which directory was put on the runtime class path.
The order of this trio is also meaningful. The strictest catch (compile) is the earliest one to
fall — the compiler stops the moment it cannot resolve the symbol; no extra step is needed. The
middle catch (runtime) is one step more permissive: the source compiles, the program starts,
the error only falls on the line where Internal is actually reached — if the program never
reached that line, the error would never even appear. The loosest case is no catch at all: in
the third setup the code both compiles and runs, and calling Internal produces no warning at
all. How strict a module boundary is depends on which of these three setups it resembles — and
this is not a language guarantee, it is an operational decision about what gets put on the
class path.
Limiting Measurement: The Boundary Is Not a Directory Separation
The three setups above built the boundary by copying into separate directories — this is also what a real build process does: carrying only the public surface into a separate output. But relying on this method misrepresents why the boundary works: it makes it look as if the boundary were a physical directory difference. The final measurement shows this is wrong.
Public and Internal still stand in the same directory in this measurement — allOutput
carries both of their class files, there is no copying at all. What builds the boundary here is
RestrictedLoader’s own allow list: Set.of("Public"). When Public is requested, this loader
reads it from disk and defines it — compute() can be called, it returns 41. But when
Internal is requested from the same loader, findClass sees it is not on the allow list and
throws a ClassNotFoundException; Internal.class sits in the exact same directory, a few
bytes away, but the loader never even looks at it.
This confirms, in reverse, what the previous three setups measured: a module boundary is not a directory separation, it is a class path and loader decision. Two types compiled from the same directory can be given separate access levels by being placed on different class paths (or given different allow lists), and this separation works just as well even with no physical directory separation at all. The escape shown by setup 3 is the mirror image of the same fact: directory separation is not a sufficient guarantee, because the real mechanism carrying the boundary is always which class path (or which loader) is allowed to see which name.
Read together, these two findings show where a multi-module project’s boundary is actually placed: the boundary depends neither on which folder the source file sits in, nor on which package name the class carries. The boundary depends on which subset of a providing module’s compile output — which directory, which archive, which allow list — is given to the consuming module. Two modules can be produced in the same repository, with the same compile call, even into the same output directory; the only thing required for the boundary to actually work is what the class path given to the consuming side does and does not contain.
Summary
- The compile-time counterpart of a module boundary is a class path decision: “public” and “internal” types compiled from the same source directory are separated by giving the consumer different class paths.
- Of three boundary setups, one is caught at compile time (direct reference, type not on the class path), one is caught at run time (reflection, type not on the runtime class path), one is never caught (reflection, but the full internal output was mistakenly added to the runtime class path).
- A class loader’s allow list looks at the class name, not the directory: of two class files in
the same directory, one can be loaded and run while the other is rejected with a
ClassNotFoundException. - Limiting measurement:
PublicandInternalkeep standing in the physically same directory; what builds the boundary is not the directory, it is the loader’s own decision.
Next Step
In this lesson, a module’s boundary was measured through which directory or which loader was put on the runtime class path — we built the boundary, we made the decision. But this declaration has not yet turned into a single running program; the runtime class path has so far always been a list of scattered directories. The next lesson turns that list into a single portable product: the compiled classes are placed into an archive, the entry point lives not in the source but in the archive’s own declaration, and whether the archive contains its dependencies or not remains a separate question.
To keep your progress and take notes, Log in
My notes
Log in to take notes.