Skip to content
academia.sh

Lesson 02 / 12

Dependency Management

The same source is first compiled against a compile class path and then run against a separate runtime class path: of six types, 1 turns out required only at compile time, 3 only at run time, 2 at both. Of four defects, 1 is a compile error, 1 is a runtime error, and 2 silently produce a wrong result without saying anything — one a choice tied to the order of two implementations of the same interface, the other the price of changing a constant without recompiling the caller.

Published

In this lesson

  • Measures which class path a dependency is required on by actually separating the two paths
  • Shows that a transitive dependency can be required at run time without ever appearing on the compile class path
  • Tests that when two implementations of the same interface are both present on the runtime class path, the choice depends on order
  • Counts compile, runtime, and silent defect classes by separating them from each other
Contents

The previous lesson showed which class path a dependency is required on with two extreme examples: one required only at compile time, one only at run time. But a real declaration usually also carries dependencies sitting in between the two — required at both compile time and run time, or never appearing at compile time and leaking into the run only through another dependency. This lesson puts six dependency types side by side and asks all of them the same question: which class path must this type be present on? The answer cannot be extracted from the source text — if it could, there would be no need for a concept called dependency scope at all.

Dependency Scope: Three Values, One Question

A dependency’s dependency scope takes one of three values: required only on the compile class path, required only on the runtime class path, or required on both. The scope is not a guess, it is a decision written in the declaration — but whether that decision is correct can only be tested by actually separating the two paths. The six types below give six different answers to the same question, and none of them can be extracted from a surface reading of the source text:

  • Constant — a class carrying a compile-time constant.

  • Tool — a helper class that calls Deep directly, not Constant.

  • Deep — a class only Tool calls, never named directly by User.

  • Facade — a single-method interface.

  • ProviderA, ProviderB — two separate implementations of Facade; they are present through a service declaration without ever being named in the source text.

  • BT7. The class path requirement of the six types is measured by first compiling the same source against a compile class path and then running it against a separate runtime class path; a trial done with only one path does not show this distinction.

  • BT8. Constant being a compile-time constant (static final int) comes from the same source as the value-embedding mechanism established in the Object-Oriented Java course; it is not re-established here, it is only used as a tool for the class path result.

  • BT9. Facade is an interface; ProviderA and ProviderB are bound to it through a service declaration (under META-INF/services). Caller never writes any implementation name in its source text at all, it only looks for Facade.

// ClassPath.java — a helper that counts which class path a type is required on and where a divergence is caught
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

public class ClassPath {
    static int compile = 0, runtime = 0, both = 0;
    static int compileError = 0, runtimeError = 0, silent = 0;

    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 source) 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());
        }
        arg.add(source.toString());
        ToolProvider compiler = ToolProvider.findFirst("javac").orElseThrow();
        StringWriter silentOutput = new StringWriter();
        int result = compiler.run(new PrintWriter(silentOutput), new PrintWriter(silentOutput), 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, ClassPath.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();
        }
    }

    static void declareProvider(Path dir, String provider) throws IOException {
        Path d = dir.resolve("META-INF").resolve("services");
        Files.createDirectories(d);
        Files.writeString(d.resolve("Facade"), provider + "\n");
    }

    static String selectedProvider(List<Path> classPath) 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, ClassPath.class.getClassLoader().getParent())) {
            Class<?> cls = Class.forName("Caller", true, loader);
            cls.getMethod("main", String[].class).invoke(null, (Object) new String[0]);
            return (String) cls.getField("selected").get(null);
        }
    }

    static int readField(List<Path> classPath, String mainClass, String field) 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, ClassPath.class.getClassLoader().getParent())) {
            Class<?> cls = Class.forName(mainClass, true, loader);
            cls.getMethod("main", String[].class).invoke(null, (Object) new String[0]);
            return cls.getField(field).getInt(null);
        }
    }

    static void classify(String name, boolean compileRequired, boolean runtimeRequired) {
        String where;
        if (compileRequired && runtimeRequired) { both++; where = "both"; }
        else if (compileRequired) { compile++; where = "compile"; }
        else { runtime++; where = "runtime"; }
        System.out.printf("%-32s %s%n", name, where);
    }

    static void defect(String name, String kind) {
        if (kind.equals("compile")) compileError++;
        else if (kind.equals("runtime")) runtimeError++;
        else silent++;
        System.out.printf("%-32s %s%n", name, kind);
    }

    static void summary() {
        System.out.printf("type   : %d compile only, %d runtime only, %d both%n", compile, runtime, both);
        System.out.printf("defect : %d compile error, %d runtime error, %d silent%n", compileError, runtimeError, silent);
    }
}

ClassPath gathers three separate measurements under one roof: which class path a type is required on (classify), which stage a defect falls at (defect), and a total of both (summary). compile and run do the same operations as in the previous lesson; declareProvider writes a service declaration to disk, and selectedProvider runs Caller and reads which implementation was selected.

Six Types, One Measurement

  • BT10. The transitive dependency (Deep) is measured never entering the compile class path at all, even though the direct dependency (Tool) is present on it; the source compiling is not proof of this.
  • BT11. Conflict resolution is measured by putting two implementations of the same interface (ProviderA, ProviderB) on the runtime class path at the same time and swapping their order; which order they are given in is the only variable.
// DependencyMeasurement.java — measures which class path six types are required on and where four defects appear
import java.nio.file.*;
import java.util.*;

public class DependencyMeasurement {
    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("dependency");
        Path source = root.resolve("source");
        Path constantD = root.resolve("constant"), toolD = root.resolve("tool"), deepD = root.resolve("deep");
        Path facadeD = root.resolve("facade"), aD = root.resolve("providerA"), bD = root.resolve("providerB");
        Path userD = root.resolve("user"), callerD = root.resolve("caller");

        Path sConstant = ClassPath.write(source, "Constant", "public class Constant { public static final int VALUE = 7; }");
        Path sDeep = ClassPath.write(source, "Deep", "public class Deep { public static int three() { return 3; } }");
        Path sTool = ClassPath.write(source, "Tool", "public class Tool { public static int two() { return Deep.three() - 1; } }");
        Path sUser = ClassPath.write(source, "User",
                "public class User { public static void main(String[] a) { int t = Constant.VALUE + Tool.two(); if (t < 0) throw new IllegalStateException(); } }");
        Path sFacade = ClassPath.write(source, "Facade", "public interface Facade { String name(); }");
        Path sA = ClassPath.write(source, "ProviderA", "public class ProviderA implements Facade { public String name() { return \"A\"; } }");
        Path sB = ClassPath.write(source, "ProviderB", "public class ProviderB implements Facade { public String name() { return \"B\"; } }");
        Path sCaller = ClassPath.write(source, "Caller",
                "import java.util.ServiceLoader;\n"
              + "public class Caller {\n"
              + "  public static String selected = \"none\";\n"
              + "  public static void main(String[] a) {\n"
              + "    for (Facade f : ServiceLoader.load(Facade.class, Caller.class.getClassLoader())) { selected = f.name(); return; }\n"
              + "  }\n"
              + "}");

        ClassPath.compile(constantD, List.of(), sConstant);
        ClassPath.compile(deepD, List.of(), sDeep);
        ClassPath.compile(toolD, List.of(deepD), sTool);
        ClassPath.compile(facadeD, List.of(), sFacade);
        ClassPath.compile(aD, List.of(facadeD), sA);
        ClassPath.compile(bD, List.of(facadeD), sB);
        ClassPath.declareProvider(aD, "ProviderA");
        ClassPath.declareProvider(bD, "ProviderB");
        ClassPath.compile(userD, List.of(constantD, toolD), sUser);
        ClassPath.compile(callerD, List.of(facadeD), sCaller);

        System.out.println("-- six types, which class path each is required on --");

        boolean constantCompile = !ClassPath.compile(root.resolve("d1"), List.of(toolD), sUser);
        boolean constantRuntime = !ClassPath.run(List.of(userD, toolD, deepD), "User").equals("ok");
        ClassPath.classify("Constant (compile-time constant)", constantCompile, constantRuntime);

        boolean toolCompile = !ClassPath.compile(root.resolve("d2"), List.of(constantD), sUser);
        boolean toolRuntime = !ClassPath.run(List.of(userD, constantD, deepD), "User").equals("ok");
        ClassPath.classify("Tool (direct dependency)", toolCompile, toolRuntime);

        boolean deepCompile = !ClassPath.compile(root.resolve("d3"), List.of(constantD, toolD), sUser);
        boolean deepRuntime = !ClassPath.run(List.of(userD, constantD, toolD), "User").equals("ok");
        ClassPath.classify("Deep (transitive dependency)", deepCompile, deepRuntime);

        boolean facadeCompile = !ClassPath.compile(root.resolve("d4"), List.of(), sCaller);
        boolean facadeRuntime = !ClassPath.run(List.of(callerD, aD), "Caller").equals("ok");
        ClassPath.classify("Facade (interface)", facadeCompile, facadeRuntime);

        boolean aCompile = !ClassPath.compile(root.resolve("d5"), List.of(facadeD), sCaller);
        ClassPath.classify("ProviderA (implementation)", aCompile, true);
        ClassPath.classify("ProviderB (second implementation)", aCompile, true);

        System.out.println();
        System.out.println("-- transitive dependency: Deep never enters the compile class path via Tool --");
        System.out.println("did User compile against the [Constant, Tool] compile class path: " + ClassPath.compile(root.resolve("transit"), List.of(constantD, toolD), sUser));
        System.out.println("Deep is not on this compile class path at all, yet User compiles");

        System.out.println();
        System.out.println("-- conflict resolution: two implementations on the runtime path, order decides the choice --");
        String firstAB = ClassPath.selectedProvider(List.of(callerD, facadeD, aD, bD));
        String firstBA = ClassPath.selectedProvider(List.of(callerD, facadeD, bD, aD));
        System.out.println("selected when A is given first: " + firstAB);
        System.out.println("selected when B is given first: " + firstBA);

        System.out.println();
        System.out.println("-- where the defect appears once the paths diverge --");
        ClassPath.defect("type absent on the compile path", ClassPath.compile(root.resolve("d6"), List.of(constantD), sUser) ? "silent" : "compile");
        ClassPath.defect("type absent on the runtime path", ClassPath.run(List.of(userD, constantD), "User").equals("ok") ? "silent" : "runtime");
        ClassPath.defect("two implementations on the runtime path", firstAB.equals(firstBA) ? "runtime" : "silent");

        Path constantNew = root.resolve("constantNew");
        ClassPath.compile(constantNew, List.of(), ClassPath.write(root.resolve("source2"), "Constant",
                "public class Constant { public static final int VALUE = 8; }"));
        Path measured = root.resolve("measured");
        Path sMeasured = ClassPath.write(source, "Measured",
                "public class Measured { public static int seen = -1;\n"
              + "  public static void main(String[] a) { seen = Constant.VALUE; } }");
        ClassPath.compile(measured, List.of(constantD), sMeasured);
        int oldValue = ClassPath.readField(List.of(measured, constantNew), "Measured", "seen");
        ClassPath.defect("constant changed, caller not recompiled", oldValue == 8 ? "runtime" : "silent");
        System.out.println("constant on the new class path : 8");
        System.out.println("value seen by the caller        : " + oldValue);

        System.out.println();
        ClassPath.summary();
    }
}
-- six types, which class path each is required on --
Constant (compile-time constant) compile
Tool (direct dependency)         both
Deep (transitive dependency)     runtime
Facade (interface)               both
ProviderA (implementation)       runtime
ProviderB (second implementation) runtime

-- transitive dependency: Deep never enters the compile class path via Tool --
did User compile against the [Constant, Tool] compile class path: true
Deep is not on this compile class path at all, yet User compiles

-- conflict resolution: two implementations on the runtime path, order decides the choice --
selected when A is given first: A
selected when B is given first: B

-- where the defect appears once the paths diverge --
type absent on the compile path  compile
type absent on the runtime path  runtime
two implementations on the runtime path silent
constant changed, caller not recompiled silent
constant on the new class path : 8
value seen by the caller        : 7

type   : 1 compile only, 3 runtime only, 2 both
defect : 1 compile error, 1 runtime error, 2 silent

The first four of the six lines were obtained by compiling and running each type one at a time; the runtime column of the last two lines is given without a separate measurement. The first three lines run User, the next three run Caller source, first trying to compile then run it against a class path that lacks that type. If compiling fails, the type is required at compile time; if running fails, it is required at run time. Constant turns out required only at compile time — its value is embedded at the call site, so it is never looked for on the runtime class path at all. Tool and Facade are required on both paths: both are named directly in the source, both have their class files looked for at both the compile and the runtime stage. Deep, ProviderA, and ProviderB are required only at run time — none of the three is ever written directly anywhere in the source text.

Result: of the six types, one is required only on the compile class path, three only on the runtime class path, two on both. This number is the shared measurement of this topic and stays unchanged throughout it.

Transitive Dependency and Conflict Resolution

Deep’s situation is the most misleading of all. Tool calls Deep directly — but User, when it calls Tool, never names Deep at all. When the compile class path contains only Constant and Tool, User compiles without trouble; Deep is not on that path at all. This is direct proof that a transitive dependency can be required at run time without ever appearing on the compile class path — a compile succeeding does not show that the runtime class path is complete.

The same source text also brings out a separate question: when two implementations of Facade (ProviderA, ProviderB) are both present on the runtime class path at the same time, which one is selected? Caller scans the service declaration and takes the first one it finds; which one is found first depends on the order they were given to the class path in. When A is given first, A is selected; when B is given first, B is selected — conflict resolution here is not a rule, it is the result of an ordering. The code never throws an error anywhere; two different setups produce two different answers from the same program.

Limiting Measurement: The Two Faces of a Silent Defect

  • BT12. The “silent” defect class gathers cases where neither the compiler nor the runtime says anything, but the result comes out different from what was expected; the expected value is also written down on every silent line.
  • BT13. When Constant is changed and the version on the runtime class path is changed without User being recompiled, the difference between the two sides is measured only by the value of the field being read; no error is thrown.

The same measurement sorts four separate defects into three classes: the absence of a type on the compile class path is rejected by the compiler (compile), the absence of a type on the runtime class path throws an error at run time (runtime), and the remaining two say nothing at all (silent). Two implementations being found on the runtime path is one of these — the program produces two different answers without ever throwing an error, and which answer is “correct” cannot be told from the code itself.

The second silent defect is more subtle: Constant’s value is raised to 8, but User is not recompiled against this new version. Even though the Constant on the runtime class path now carries 8, the value already embedded in User’s class file stays at 7 — because the embedding happened at compile time, not at run time. The class path looks updated, but the compiled class still carries the old value; this line falls into the silent class because neither compiling nor running produces a complaint — the result comes out wrong, nothing more.

In total: of four defects, one is a compile error, one is a runtime error, two are silent. “It compiled” is therefore not proof — a transitive dependency can be entirely absent from the compile class path while the source compiles without trouble, yet still be required at run time; a compile succeeding does not show that the runtime class path is complete. A type not being required on the runtime class path does not mean it is not depended on either — the absence of Constant means it was copied, and that copy silently produces a wrong result once the two sides diverge.

Summary

  • Dependency scope takes three values (compile, runtime, both); measured across six types, the distribution comes out 1 compile only, 3 runtime only, 2 both, and it cannot be extracted from the source text.
  • A transitive dependency (Deep) compiles without trouble while never appearing on the compile class path at all, but is required at run time; “it compiled” is not proof.
  • Conflict resolution becomes order-dependent once two implementations of the same interface are both present on the runtime class path; there is no error, the result changes from setup to setup.
  • Of four defects, one is a compile error, one is a runtime error, two are silent: in silent defects neither the compiler nor the runtime says anything, and the result comes out wrong, nothing more.
  • A type not being required on the runtime class path does not mean it is not depended on: its value may have been embedded, and that embedded value silently goes stale once the source changes.

Next Step

This lesson measured six standalone types — each stayed within the boundary of a single class or interface. But a real project is made not of single-file classes, it is made of interdependent modules, and the question of what a module’s boundary corresponds to at compile time has not yet been asked. The next lesson measures that: when a type a module considers “internal” is reached across the boundary, is it caught at compile time, at run time, or not caught at all — and is the source of that catch really a directory separation.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close