At what point in the Gradle lifecycle is the project graph assembled from include() calls, and why does that ordering matter?
answer
- three phases: init, config, execution
- include runs in initialization
- graph frozen before configuration
- existence guaranteed, not configured state
- can't add projects from a build script
basics
~20 sThe project graph is built during the settings (initialization) phase, when Gradle evaluates settings.gradle.kts and runs every include(). This happens before any build.gradle.kts is configured, so the full set of projects is known before configuration starts.
solid answer
~40 sGradle runs three phases: **initialization**, **configuration**, **execution**. `include` executes in **initialization**, when the settings file is evaluated. By the end of that phase Gradle has a complete, immutable set of `ProjectDescriptor`s — the project graph. Only then does **configuration** begin, evaluating each project's `build.gradle.kts`. This ordering is fundamental: because the graph exists before any build script runs, a build script can safely reference sibling projects (`project(":lib")`, `dependencies { implementation(project(":lib")) }`) without worrying whether they've been included yet — they always have. It also means you cannot add a project from a build script (too late), and tools like the configuration cache and IDE import can read the graph deterministically from the settings file alone. Settings-phase code (`include`, `projectDir`, `rootProject.name`) is the only place to shape membership.
code
kotlin · 6 lines// settings.gradle.kts (initialization phase)
rootProject.name = "shop"
include(":api", ":domain", ":web")
// web/build.gradle.kts (configuration phase) — :domain already exists
dependencies { implementation(project(":domain")) }go deeper
Know include runs first, before build scripts, so projects exist early.
Name the three phases and place include in initialization; explain the graph is frozen before configuration.
Articulate why cross-project existence is guaranteed but configured state is not, and why membership can't be changed from a build script.
Connect the phase boundary to deterministic tooling/configuration-cache behavior and to keeping settings evaluation cheap and side-effect-free at org scale.
## The three phases A Gradle invocation proceeds through three ordered phases: 1. **Initialization (settings phase)** — Gradle finds and evaluates the **settings file**. Every `include`, `includeBuild`, `project(...).projectDir`, and `rootProject.name` runs here. The output is the project hierarchy: a tree of `ProjectDescriptor` objects with one root and its subprojects. 2. **Configuration** — Gradle evaluates each project's `build.gradle.kts`, creating the actual `Project` objects, plugins, extensions, and the task graph. 3. **Execution** — the requested tasks run. ## Why include belongs to phase 1 The whole point of putting `include` in the settings file is that **membership must be known before any configuration runs**. Consider a build script: ```kotlin // app/build.gradle.kts dependencies { implementation(project(":lib")) } ``` For `project(":lib")` to resolve, `:lib` must already exist. Because every `include` finished in initialization, the project always exists by the time any build script is configured — regardless of which project Gradle happens to configure first. There is no ordering hazard between *whether a project exists* and *when it's referenced*. ## Consequences of the ordering - **You cannot add projects from a build script.** By configuration time the graph is frozen. Attempting to influence membership there is too late. - **Cross-project references are safe, but cross-project *configuration* ordering is not.** Existence is guaranteed; the *configured state* of another project may not be ready when you read it. That subtlety (e.g. reading another project's extension values) is a separate sibling topic — here the guarantee is purely that the project node exists. - **Deterministic tooling.** The configuration cache, `./gradlew projects`, and IDE import all derive the structure from the settings file in initialization, without running build logic. This is why settings evaluation is kept cheap and side-effect-light. ## A useful framing Initialization answers *"which projects are in this build?"*; configuration answers *"what does each project do?"*; execution answers *"do the work."* `include` is purely an initialization-phase, membership-defining call. ```kotlin // settings.gradle.kts — initialization phase rootProject.name = "shop" include(":api", ":domain", ":web") // graph is complete here; configuration of build scripts has not begun ```
- Why can I safely reference project(":domain") in any build script even if :domain hasn't been configured yet?Because include() ran in initialization, the project node exists before any configuration begins. Referencing its existence (and declaring a dependency) is safe; only reading its *configured* values would risk ordering issues.
- Could I conditionally include a project based on a system property?Yes — include is normal Kotlin running in initialization, so you can guard it with an if on a Gradle/system property. The decision is just made in the settings file, before configuration.
- Why must settings evaluation stay cheap?Tooling (IDE import, configuration cache, ./gradlew projects) runs the settings phase to learn the graph without executing build logic, so heavy work there slows every invocation.
Initialization is the guest list; configuration is each guest getting dressed; execution is the party. You can't add a guest once people are already dressing — the list was locked when the invites went out.
saying these in an interview costs you the question
- Saying the project graph is built during configuration or execution.
- Claiming you can add a project from a build.gradle.kts.
- Confusing 'project node exists' with 'project is fully configured'.