---
title: 'The Build System'
source: 'https://academia.sh/en/courses/android-platform-and-tools/build-system'
course: 'Android Platform and Tools'
language: en
updated: '2026-10-21T05:02:36+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Distinguishes the roles of the project settings script, the module build script and the version catalog'
    - 'Measures that in a transitive dependency the module that wants the lower version is fulfilled by substitute and the one that wants the higher version is fulfilled in full'
    - 'Shows that the version catalog gives the same version to all modules'
    - 'Verifies with the bounding measurement that a strict version mismatch stays unmet and that version ordering is numeric'
---

# The Build System

A model of how the build system reads dependencies and the version catalog, how the highest version is selected for a transitive dependency, and how a strict version constraint behaves.

The previous lesson measured how resources are selected according to the configuration. The manifest, the resources and the code are not an app on their own; the build system is what brings them together and turns them into an installable package. This lesson introduces the main parts of the build system and measures what happens when the dependency version one module wants collides with the requests of other modules.

The measurement axis of the course does not change: how does the platform fulfill this request. There are four fulfillments: full, substitute, pending and unmet. The rule, not a label, gives the fulfillment: a given value that equals the wanted one is full, and one that differs is substitute; pending and unmet come directly from the outcome. The file below carries that rule and is repeated verbatim in every lesson. The count of 3/2/2/1 from the opening lesson is not changed here.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

## The Parts of the Build System

A project is set up with three separate written documents. The project settings script gives the name of the project and says which modules it consists of. Each module has its own module build script: it defines the module's compile API level, minimum and target API levels, build types and dependencies. The version catalog collects the names and versions of the dependencies in one place. The two fragments below show the project settings script and a module build script; both are scripts that the platform tools read, and they are not run.

```kotlin
// project settings script
rootProject.name = "Notes"
include(":app", ":data")
```

```kotlin
// module build script
android {
    namespace = "com.example.notes"
    compileSdk = 34
    defaultConfig {
        minSdk = 24
        targetSdk = 34
    }
    buildTypes {
        release { isMinifyEnabled = true }
    }
}

dependencies {
    implementation(project(":data"))
    implementation(libs.notesCore)
}
```

The script knows two build types: `debug`, used during development, and `release`, prepared for distribution. Each type carries its own settings; here code shrinking is turned on for `release`. A dependency declaration can be written in two forms: another module with `project(":data")`, or a library from the version catalog with `libs.notesCore`. The version catalog is a separate file.

```toml
[versions]
notesCore = "1.4"

[libraries]
notesCore = { module = "com.example:notes-core", version.ref = "notesCore" }
```

The build system runs each step as a build task: it processes the resources, compiles the code, packages. The tasks depend on each other, and the system derives the order from the dependencies. What this lesson measures is not the order of the build tasks but the resolution of dependency versions.

## Version Selection in a Transitive Dependency

A module asks for a library directly; that library depends on other libraries. These are called transitive dependencies. The same library can be requested along the way several times, with different versions. The build system selects a single version, and the model builds this selection as the highest version. Each request is a separate question: the wanted value is the version the module wrote, and the given value is the version the resolution selected.

```kotlin
// Resolver.kt — a model of version resolution
class Request(val from: String, val library: String, val version: String, val strict: Boolean = false)

fun compareVersions(a: String, b: String): Int {
    val left = a.split(".").map { it.toInt() }
    val right = b.split(".").map { it.toInt() }
    for (i in 0 until maxOf(left.size, right.size)) {
        val diff = left.getOrElse(i) { 0 } - right.getOrElse(i) { 0 }
        if (diff != 0) return diff
    }
    return 0
}

fun signOf(n: Int): Int = if (n < 0) -1 else if (n > 0) 1 else 0

fun resolveDependency(requests: List<Request>): List<Ask> {
    val strictVersions = requests.filter { it.strict }.map { it.version }.toSet()
    val highest = requests.map { it.version }.reduce { best, next ->
        if (compareVersions(next, best) > 0) next else best
    }
    return requests.map { request ->
        val outcome = when {
            strictVersions.size > 1 -> Outcome.Refused("strict-version-mismatch")
            strictVersions.size == 1 -> Outcome.Given(strictVersions.single())
            else -> Outcome.Given(highest)
        }
        Ask("${request.from}>${request.library}", request.version, outcome)
    }
}
```

In the first measurement there are two libraries. The app module asks for the `notes-core` library with version 1.2, and the data module asks for it with version 1.4. Only the app module asks for the `net-client` library.

```kotlin
// Main.kt — the highest version is selected
fun main() {
    val core = resolveDependency(listOf(
        Request("app", "notes-core", "1.2"),
        Request("data", "notes-core", "1.4"),
    ))
    val net = resolveDependency(listOf(Request("app", "net-client", "3.0")))
    val asks = core + net
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
app>notes-core: wanted=1.2 | given=1.4 -> substitute
data>notes-core: wanted=1.4 | given=1.4 -> full
app>net-client: wanted=3.0 | given=3.0 -> full
full: 2
substitute: 1
pending: 0
unmet: 0
```

The data module got the version it wanted. The app module asked for 1.2 and got 1.4: a higher version is a substitute, because the given value differs from the wanted one. The rule does not say whether this version is better or worse; it says only that it is not equal. For a library with a single request there is nothing to resolve, and the result is full.

**PS1.** The model builds only the rule of selecting the highest version. The real resolver also reads other inputs, such as range versions, version weights and exclusions.

**PS2.** Version strings consist only of numbers separated by dots. Pre-release suffixes and versions that contain letters are outside the model.

## The Version Catalog

The purpose of the version catalog is to prevent the same library from being written separately in each module. The modules do not write the version themselves; they use the name in the catalog, and the version comes from one place. The measurement below asks this in two cases. With the catalog, all three modules ask for the same version. Without the catalog, each module has written a version by hand and the three versions have drifted apart.

```kotlin
// Main.kt — with and without the catalog
fun main() {
    val withCatalog = resolveDependency(listOf(
        Request("app", "ui-kit", "2.0"),
        Request("data", "ui-kit", "2.0"),
        Request("sync", "ui-kit", "2.0"),
    ))
    val withoutCatalog = resolveDependency(listOf(
        Request("app", "ui-kit", "1.8"),
        Request("data", "ui-kit", "1.9"),
        Request("sync", "ui-kit", "2.0"),
    ))
    val asks = withCatalog + withoutCatalog
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
app>ui-kit: wanted=2.0 | given=2.0 -> full
data>ui-kit: wanted=2.0 | given=2.0 -> full
sync>ui-kit: wanted=2.0 | given=2.0 -> full
app>ui-kit: wanted=1.8 | given=2.0 -> substitute
data>ui-kit: wanted=1.9 | given=2.0 -> substitute
sync>ui-kit: wanted=2.0 | given=2.0 -> full
full: 4
substitute: 2
pending: 0
unmet: 0
```

With the catalog, all three requests came out full. Without the catalog, the module that asks for the highest version is full and the others are substitute. The result of reading the version from the catalog is that it removes the chance of drift; the measure still looks only at the equality of the given and the wanted.

## Project and Module Separation

The project settings script and the module build script work at two different levels. At the project level, where dependencies are downloaded from and which modules are part of the project are determined. At the module level, what that module depends on and how it is built are determined. The fragment below shows a dependency repository declaration at the project level.

```kotlin
// project settings script
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
```

Because the repository declaration is at the project level, every module resolves dependencies from the same places. A module's script can also define its own build types, `debug` and `release`. A build type carries settings such as code shrinking, debuggability and signing. When there is no flavor dimension, the build variant carries the same name as the build type; the next lesson measures that flavors enter this product.

## Dependency Configurations

A dependency is declared together with the classpath it will appear on. The first word of the declaration is the configuration. `implementation` adds the dependency to the module's compilation and run but does not show it to other modules that use the module. `api` also puts the same dependency on the compile classpath of the modules that use it. `compileOnly` enters only the compilation and does not enter the running app. `runtimeOnly` enters only the run. `testImplementation` enters only the test compilation. The model binds to each configuration a set that says which of four classpaths it reaches: compile, runtime, consumer and test.

```kotlin
// Config.kt — a model of dependency configurations
val reach: Map<String, Set<String>> = mapOf(
    "implementation" to setOf("compile", "runtime"),
    "api" to setOf("compile", "runtime", "consumer"),
    "compileOnly" to setOf("compile"),
    "runtimeOnly" to setOf("runtime"),
    "testImplementation" to setOf("test"),
)

fun resolveConfiguration(configuration: String, classpath: String): Ask {
    val paths = reach[configuration]
    val outcome = when {
        paths == null -> Outcome.Refused("no-configuration")
        classpath in paths -> Outcome.Given(classpath)
        else -> Outcome.Refused("not-on-classpath")
    }
    return Ask(configuration, classpath, outcome)
}
```

In the first measurement, a library belonging to the same module is wanted to be present at run time. Three configurations are asked.

```kotlin
// Main.kt — the runtime classpath
fun main() {
    val asks = listOf("implementation", "runtimeOnly", "compileOnly").map { resolveConfiguration(it, "runtime") }
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
implementation: wanted=runtime | given=runtime -> full
runtimeOnly: wanted=runtime | given=runtime -> full
compileOnly: wanted=runtime | reason=not-on-classpath -> unmet
full: 2
substitute: 0
pending: 0
unmet: 1
```

If an interface library is needed at compile time but another package supplies it at run time, `compileOnly` is the right choice. A wrong choice leads to the class not being found at run time. The model shows this as unmet. The second measurement asks the edge cases: appearing on the consumer module's compile classpath, appearing on the test classpath, and a configuration that was never defined.

```kotlin
// Main.kt — consumer, test and unknown
fun main() {
    val asks = listOf(
        resolveConfiguration("api", "consumer"),
        resolveConfiguration("implementation", "consumer"),
        resolveConfiguration("testImplementation", "compile"),
        resolveConfiguration("debugImplementation", "runtime"),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
api: wanted=consumer | given=consumer -> full
implementation: wanted=consumer | reason=not-on-classpath -> unmet
testImplementation: wanted=compile | reason=not-on-classpath -> unmet
debugImplementation: wanted=runtime | reason=no-configuration -> unmet
full: 1
substitute: 0
pending: 0
unmet: 3
```

`api` and `implementation` compile the same library in the same module, but their visibility to consumers is separated. This distinction matters in the long run: unnecessary use of `api` can cause a change in one module to force every module that uses it to be recompiled. The model does not measure this cost; it measures only access.

**PS3.** The model reads the configuration name from a small fixed table. In a real build system, configurations multiply with the build type (for example, ones specific to only one build type), and a module can define its own configuration.

## The Bounding Measurement

The model is limited in two places. The first is the ordering of versions. Version `1.10` is higher than version `1.9`, because a version is compared by its numeric parts. If the same two strings are ordered alphabetically, the result reverses. `compareVersions` applies numeric ordering, and the string `compareTo` applies alphabetical ordering; the first two lines print the sign of each. The second is the strict version constraint (`strictly`): if two modules wrote different strict versions for the same library, the resolution cannot select a version.

```kotlin
// Main.kt — ordering and the strict constraint
fun main() {
    println("numeric: ${signOf(compareVersions("1.10", "1.9"))}")
    println("string: ${signOf("1.10".compareTo("1.9"))}")
    val ordering = resolveDependency(listOf(
        Request("app", "log-tools", "1.9"),
        Request("data", "log-tools", "1.10"),
    ))
    val strict = resolveDependency(listOf(
        Request("app", "crypto-lib", "2.0", strict = true),
        Request("sync", "crypto-lib", "2.1", strict = true),
    ))
    val asks = ordering + strict
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
numeric: 1
string: -1
app>log-tools: wanted=1.9 | given=1.10 -> substitute
data>log-tools: wanted=1.10 | given=1.10 -> full
app>crypto-lib: wanted=2.0 | reason=strict-version-mismatch -> unmet
sync>crypto-lib: wanted=2.1 | reason=strict-version-mismatch -> unmet
full: 1
substitute: 1
pending: 0
unmet: 2
```

In numeric ordering the sign is positive: `1.10` is higher. In string ordering the sign is negative: the opposite comes out for the same two strings. That is why the `app>log-tools` request became substitute and `data>log-tools` stayed full. The model builds numeric ordering; the measurement shows only that the model applies this ordering. With the strict constraint, because the two strict versions differ, the resolution gave no version and both requests came out unmet. The rule does not see the reason for the mismatch; the `strict-version-mismatch` string that the model carries gives the reason.

**PS4.** The model counts the strict constraint as unmet only when the two strict versions differ from each other. When there is a single strict version, it gives that version; details such as whether a strict version can drop another request are not measured.

## Summary

- This lesson's criterion: **fulfillment**; whether the version the resolution gave equals the version the module wanted. This lesson's fulfillment: **substitute**.
- The project settings script lists the modules, the module build script defines a module's settings and dependencies, and the version catalog collects names and versions in one place.
- `debug` and `release` are build types; each type has its own settings.
- In a transitive dependency the model selects the highest version: the one that asks for the lower version is fulfilled by substitute, and the one that asks for the higher version in full.
- The version catalog prevents drift, because it gives the same version to all modules.
- Version ordering is numeric; a strict version constraint mismatch is unmet.

## Next Step

The build system can produce several apps from the same source: free and paid versions, or packages for different markets. The next lesson measures product flavors and the build variants that arise from their product with build types.
