---
title: 'Build Tool Model'
source: 'https://academia.sh/en/courses/java-ecosystem/build-tool-model'
course: 'Build, Testing and the Ecosystem'
language: en
updated: '2026-09-13T05:08:57+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Separates the phase sequence derived from a declaration from the declaration's writing order"
    - 'Counts and shows that the number of declared items and the number of derived phases are not the same measure'
    - 'Actually runs and separates the compile class path from the runtime class path that come out of the same declaration'
---

# Build Tool Model

The phase sequence a build tool derives from a declaration is fixed and unaffected by the declaration's writing order: three declared items yield four phases, and both a forward and a reversed writing of the same declaration derive the same four phases. Two class paths coming out of the same declaration — one for compiling, one for running — are separated by an annotation type kept at the source level: it never appears in the class file's constant pool, and it is never required on the runtime class path either.

The closing lesson of the JVM, Concurrency and Performance course measured a single question:
which thread runs a step could not be read from the call syntax — it could only be seen by
running it. The same line was the same syntax whether it ran on one thread or another; what
decided the running side sat outside the source text.

This lesson asks the same question one layer out. A Java program does not live against a single
class path, it lives against two separate class paths: the one it was compiled against and the
one it runs on. The source text — even the compiled class file itself — does not say what these
two paths need to contain; what says it is a declaration standing outside the source. What the
compiler needs to see a type and what the runtime needs to find that same type are not the same
set, and the tool that produces this distinction is this lesson's and this topic's question.

The tool's name is not written down — which product, which syntax is used is not this lesson's
question. What is asked is this: how much of a few lines of declaration does a build tool
derive on its own, and why does what it derives split into two separate lists that never appear
in the source at all?

All three topics in this course are fields usually referred to by third-party tool names; this
is why the build tool itself is built here as a **behavior**, not a product. What is measured is
the rule that derives a phase sequence from a declaration and the two class paths that come out
at the end of those phases — not which product accepts which flag. The standard library itself
provides the real, running part of this model: the compiler is called from inside the program
with `ToolProvider`, and it actually compiles.

## What a Build Tool Derives

What a declaration derives is not a single list, it is two separate things: a **phase
sequence** (what happens in what order) and the **class paths** built within those phases
(which directories each phase looks at). These two things must not be confused with each other.
The "compile sources" phase runs a single compile task: it calls the compiler once. The "build
compile class path" phase runs a task that goes through every dependency in the declaration one
by one — it adds every dependency whose scope is compile or both to the list, and skips every
dependency whose scope is runtime only. The same phase, "build runtime class path," filters the
same dependency list this time from the opposite side. The phase itself is one thing, but the
compile task within it does a varying amount of work depending on the declaration's length —
this also explains why the two numbers seen separately in the measurement below (declared item,
derived phase) need not turn out equal.

## The Phase Sequence Derived from a Declaration

What is given to a build tool is not a step-by-step procedure, it is a **declaration**: which
source directory is to be compiled, which dependencies are to be used with which **dependency
scope**. The tool itself derives a **build lifecycle** from this declaration — a **phase**
sequence that runs in a fixed order. Each phase runs one or more **tasks**; only the phases
themselves are measured here, not the content of the tasks.

The `Declaration` class below holds a dependency only by its name and its scope; the scope
takes one of three values: required only at compile time, required only at run time, or both.

- **BT1.** The build tool is built here as a model; no real product's name, target name,
  command-line flag, or declaration syntax is used. The phase names and the declaration's shape
  are this lesson's own decision.
- **BT2.** Declared item count is a total in which every line of the declaration — the source
  directory counts as one, each dependency counts as one — counts as a single item. A
  dependency's scope does not change the item count.
- **BT3.** The derived phase sequence consists of a fixed four phases and is unaffected by the
  dependency count or by the declaration's writing order; the order is the tool's own rule, not
  the declaration's preference.

```java
// Declaration.java — the declaration given to a build tool: source directory name and dependency scope labels
import java.util.*;

public class Declaration {
    record Dependency(String name, String scope) {}

    final String sourceName;
    final List<Dependency> dependencies;

    Declaration(String sourceName, List<Dependency> dependencies) {
        this.sourceName = sourceName;
        this.dependencies = dependencies;
    }

    int declaredItemCount() {
        return 1 + dependencies.size();
    }
}
```

```java
// Phases.java — the phase sequence derived from the declaration: order does not look at the declaration's writing order
import java.util.*;

public class Phases {
    static List<String> derive(Declaration d) {
        List<String> sequence = new ArrayList<>();
        sequence.add("clean");
        sequence.add("compile sources");
        sequence.add("build compile class path");
        sequence.add("build runtime class path");
        return sequence;
    }
}
```

`Phases.derive` produces four phases without ever looking at the declaration's content:
cleaning, compiling sources, building the compile class path, building the runtime class path.
Whether the declaration's dependency count is one, two, or ten, the length of the returned
sequence does not change — because what is measured is not the dependencies themselves, it is
what the tool derives from them.

## Two Numbers, Two Class Paths from the Same Declaration

The measurement starts with two separate declarations: the same two dependencies, one written
in forward order, the other in reverse order. `Tag` is marked as required only at compile time,
`Service` as required only at run time. `Tag` is an annotation type kept at the source level by
its retention policy: the compiler uses it in type checking, but it leaves no trace at all in
the class file. Access to `Service` is built the exact opposite way: `Program` never names it
directly at all, it only looks it up by a name string — which is why it never needs to be on the
compile class path at all.

- **BT4.** Tag is an annotation type kept at the source level by its retention policy; the
  compiler uses it in type checking and leaves no reference to it in the class file.
- **BT5.** Access to Service is built not through a direct type reference but through a call
  that looks it up by name; this is why it never enters the compile class path at all.
- **BT6.** The compile class path and the runtime class path are built as two separate directory
  lists; there is no directory shared between them at all — Tag's directory is given only at
  compile time, Service's directory only at run time.

```java
// ToolModel.java — the phase sequence derived from a declaration is independent of declaration order, and one declaration yields two class paths
import java.io.*;
import java.lang.classfile.*;
import java.lang.classfile.constantpool.*;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

public class ToolModel {

    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 silent = new StringWriter();
        int result = compiler.run(new PrintWriter(silent), new PrintWriter(silent), arg.toArray(new String[0]));
        return result == 0;
    }

    static boolean constantPoolContainsName(Path classFile, String name) throws IOException {
        ClassModel model = ClassFile.of().parse(classFile);
        for (PoolEntry e : model.constantPool()) {
            if (e instanceof ClassEntry ce && ce.asInternalName().equals(name)) return true;
        }
        return false;
    }

    public static void main(String[] args) throws Exception {
        var tag = new Declaration.Dependency("Tag", "compile");
        var service = new Declaration.Dependency("Service", "runtime");

        Declaration forward = new Declaration("source", List.of(tag, service));
        Declaration reverse = new Declaration("source", List.of(service, tag));

        List<String> phasesForward = Phases.derive(forward);
        List<String> phasesReverse = Phases.derive(reverse);

        System.out.println("-- phase sequence, independent of declaration order --");
        System.out.println("phases derived from the forward declaration: " + phasesForward);
        System.out.println("phases derived from the reverse declaration: " + phasesReverse);
        System.out.println("are the two phase sequences the same        : " + phasesForward.equals(phasesReverse));
        System.out.println("declared item count                         : " + forward.declaredItemCount());
        System.out.println("derived phase count                         : " + phasesForward.size());

        Path root = Files.createTempDirectory("tool");
        Path tagD = root.resolve("tag"), serviceD = root.resolve("service"), programD = root.resolve("program");

        Path sTag = write(root.resolve("source"), "Tag",
                "import java.lang.annotation.*;\n"
              + "@Retention(RetentionPolicy.SOURCE)\n"
              + "public @interface Tag { String value(); }");
        Path sService = write(root.resolve("source"), "Service",
                "public class Service { public static String run() { return \"service ran\"; } }");
        Path sProgram = write(root.resolve("source"), "Program",
                "@Tag(value = \"example\")\n"
              + "public class Program {\n"
              + "  public static void main(String[] a) throws Exception {\n"
              + "    Class<?> s = Class.forName(\"Service\");\n"
              + "    String r = (String) s.getMethod(\"run\").invoke(null);\n"
              + "    System.out.println(\"service (reflection): \" + r);\n"
              + "  }\n"
              + "}");

        compile(tagD, List.of(), sTag);
        compile(serviceD, List.of(), sService);

        List<Path> compileClassPath = List.of(tagD);
        boolean programCompiled = compile(programD, compileClassPath, sProgram);
        System.out.println();
        System.out.println("-- one declaration, two class paths --");
        System.out.println("did Program compile against the compile class path (Tag only)        : " + programCompiled);
        System.out.println("does Program.class's constant pool hold a reference to Tag            : "
                + constantPoolContainsName(programD.resolve("Program.class"), "Tag"));

        List<Path> runtimeClassPath = List.of(programD, serviceD);
        URL[] url = new URL[runtimeClassPath.size()];
        for (int i = 0; i < url.length; i++) url[i] = runtimeClassPath.get(i).toUri().toURL();
        try (URLClassLoader loader = new URLClassLoader(url, ToolModel.class.getClassLoader().getParent())) {
            Class<?> cls = Class.forName("Program", true, loader);
            System.out.println("does Program run against the runtime class path (no Tag, has Service):");
            cls.getMethod("main", String[].class).invoke(null, (Object) new String[0]);
        }
    }
}
```

```
-- phase sequence, independent of declaration order --
phases derived from the forward declaration: [clean, compile sources, build compile class path, build runtime class path]
phases derived from the reverse declaration: [clean, compile sources, build compile class path, build runtime class path]
are the two phase sequences the same        : true
declared item count                         : 3
derived phase count                         : 4

-- one declaration, two class paths --
did Program compile against the compile class path (Tag only)        : true
does Program.class's constant pool hold a reference to Tag            : false
does Program run against the runtime class path (no Tag, has Service):
service (reflection): service ran
```

The first half of the output shows that declaration order does not affect the phase sequence:
the phase sequences derived from the forward and the reverse declaration are exactly the same;
the `equals` comparison confirms it correctly. Declared item count is **3** (one source
directory, two dependencies), derived phase count is **4** — the two numbers are independent of
each other, neither determines the other. What the tool derives depends not on the
declaration's length but on its own rule.

The second half measures the actual claim. `Program` is compiled against a compile class path
holding only `Tag`, and the compile succeeds — the compiler used `Tag` in type checking but left
no trace in the class file; the name `Tag` never appears in the constant pool at all; this line
is confirmed by reading the class file, not by guessing. Then the same compiled `Program` is run
against an entirely different runtime class path: on this path, `Tag` does not exist at all, but
`Service` does. The program runs without error and finds `Service` through reflection. The set
of names the compiler saw (`Tag`, `Program`) and the set of names the runtime found (`Service`,
`Program`) do not overlap — from the same single declaration, from the same single compiled
class, two separate class paths came out.

These two results must be read together, on the same line. The constant pool check gives
negative evidence: `Tag` is not there, therefore the runtime class path has no need to contain
it — this rests directly on the class file itself, not on a guess. The runtime trial gives
positive evidence: `Service` really is found on the runtime class path and really is called. The
two pieces of evidence together show that the compile class path and the runtime class path are
two separate sets coming out of the same declaration, and that neither is required to be a
subset or a superset of the other.

## Limiting Measurement: Order Is a Rule, Not a Preference

The first line of the measurement above sets a limit: the phase order turning out independent
of declaration order is not an observation, it is a consequence of the tool's model.
`Phases.derive` never looks at the declaration's `dependencies` list at all; it only knows that
the declaration exists. This is why the phase sequence does not change even when the same
dependency set is given with its writing order reversed — if order were a preference, two
declarations would have produced two separate sequences.

This distinction matters, because the relationship between a declaration and a phase sequence
is not a mapping, it is a rule application. Deleting or adding a line in the declaration does
not change the derived phases' **content**, it only changes the scope of the compile tasks
**inside** those phases — for example, the phase that builds the compile class path now
contains one more dependency. The phase order itself stays fixed.

The same rule is also independent of the declaration's length. The declaration in the
measurement carried two dependencies; if the declaration had carried five, ten, or none at all,
`Phases.derive` would still return four phases, because the method's body never reads the
dependency list at all. This shows that the tool is not an **interpreter**, it is a **rule
applier**: no matter how much the input changes, the phase set stays the same, and the only thing
that changes is how much data the compile task running inside each phase works with. The six
types measured in the next lesson will also pass **through** this same fixed phase set — both
the "build compile class path" and "build runtime class path" phases will see the same six
dependencies, but each will select a different subset from among them.

The next lesson deepens this distinction: a dependency's scope determines not only the list
inside that one phase but also the content of the two class paths — and that determination will
give the full measure of the two separate sets (one required at compile time, one at run time)
seen in this lesson.

## Summary

- A build tool derives a fixed phase sequence from a declaration; the order is independent of
  the declaration's writing order and is the tool's own rule.
- Declared item count (the number of lines in the declaration) and derived phase count are
  separate concepts; neither determines the other — here 3 declared items yield 4 phases.
- Two separate class paths come out of the same declaration: the compile class path and the
  runtime class path; which one a type is required on depends on how the type is used in the
  source (a direct reference or a lookup by name).
- An annotation type kept at the source level by its retention policy (`Tag`) is an example of
  a dependency the compiler sees but the class file never carries at all; this is confirmed
  directly by reading the constant pool.

## Next Step

This lesson showed, with two examples — one at compile time, one at run time — which class path
a dependency is required on. But when six separate dependency types, including a direct
dependency and a transitive dependency, a single interface and two of its implementations, are
all present together, the picture becomes far more complicated. The next lesson measures
dependency scope itself: of six types, how many are required only at compile time, how many
only at run time, how many at both — and of the four defects that appear once the two class
paths diverge, how many make no sound at all.
