---
title: 'Multi-Module Projects'
source: 'https://academia.sh/en/courses/java-ecosystem/multi-module-projects'
course: 'Build, Testing and the Ecosystem'
language: en
updated: '2026-09-13T05:08:57+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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"
---

# 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.

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.** `Public` and `Internal` are 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.

```java
// 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 the `Internal` type at all in the restricted
  loader's measurement; this is why `Public` loading and running is measured independently of
  whether `Internal` is reachable or not.

```java
// 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);
        }
    }
}
```

```java
// 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: `Public` and `Internal` keep 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.
