skip to content

How do you build a multi-project hierarchy and force project evaluation with ProjectBuilder, and what are the pitfalls?

level: seniorimportance: should knowfreq 30%

answer

  1. withParent / withName / withProjectDir
  2. projects built UNEVALUATED
  3. afterEvaluate doesn't fire automatically
  4. (project as ProjectInternal).evaluate() — internal API
  5. subprojects/allprojects + cross-project rely on TestKit

basics

~10 s

Build a root with ProjectBuilder.builder().build(), then children with .withParent(root).withName("child").build(). ProjectBuilder doesn't auto-evaluate, so afterEvaluate won't fire unless you trigger evaluation via internal APIs (ProjectInternal.evaluate()).

solid answer

~50 s

ProjectBuilder can model a hierarchy: build the root, then `ProjectBuilder.builder().withParent(root).withName("app").build()` for each subproject; you can also set `.withProjectDir(dir)`. The key pitfall is **evaluation**: ProjectBuilder builds projects in an *unevaluated* state. Plugin code inside `project.afterEvaluate { ... }` (or anything deferred to lifecycle) does **not** run just because you built and applied. To exercise that code you must force evaluation, typically by casting to `ProjectInternal` and calling `evaluate()`. This is an internal API — brittle across versions — which is one reason `afterEvaluate`-heavy logic is better tested with TestKit. Other pitfalls: cross-project task dependencies and `subprojects {}` / `allprojects {}` blocks behave subtly because they often rely on evaluation order; and ProjectBuilder's hierarchy doesn't run a real settings/configuration lifecycle, so it can diverge from a genuine multi-project build. Use it for per-project wiring; use TestKit for cross-project lifecycle behaviour.

code

kotlin · 12 lines
kotlin
import org.gradle.api.internal.project.ProjectInternal

val root = ProjectBuilder.builder().withName("root").build()
val app = ProjectBuilder.builder().withParent(root).withName("app").build()

app.plugins.apply(GreetingPlugin::class.java)
// afterEvaluate{} wiring hasn't run yet:
assertNull(app.tasks.findByName("greet"))

// force evaluation (internal API) to fire afterEvaluate hooks
(app as ProjectInternal).evaluate()
assertNotNull(app.tasks.findByName("greet"))

go deeper

for a junior

Know you can set a parent with withParent and a name with withName; deeper evaluation concerns are beyond junior scope.

for a middle

Build a parent/child tree and recognize that afterEvaluate may not fire; know findByProjectDir/withProjectDir options.

for a senior

Explain the unevaluated state, force evaluation via ProjectInternal.evaluate(), and articulate why afterEvaluate-heavy logic is better in TestKit.

for a principal

Set guidance on when in-process internal-API evaluation is acceptable vs mandating TestKit for lifecycle/cross-project coverage, isolating brittleness behind helpers.

## Building a hierarchy `ProjectBuilder` supports parent/child relationships through the builder: ```kotlin val root = ProjectBuilder.builder().withName("root").build() val app = ProjectBuilder.builder().withParent(root).withName("app").build() val lib = ProjectBuilder.builder().withParent(root).withName("lib").build() ``` Useful builder options: - `withName(String)` — sets the project name/path segment. - `withParent(Project)` — places the project under a parent, forming the tree. - `withProjectDir(File)` — points the project at a real directory (handy when the plugin reads files/layout). - `withGradleUserHomeDir(File)` — isolates the Gradle user home. ## The evaluation pitfall This is the single most important gotcha. **ProjectBuilder produces projects that have not been evaluated.** In a real build, after the build script (and plugin `apply`) runs, Gradle *evaluates* the project, firing `afterEvaluate {}` hooks and any logic deferred to that point. With ProjectBuilder, applying the plugin runs the immediate `apply()` body, but the project is never evaluated automatically — so deferred code never runs. If your plugin defers wiring like this: ```kotlin override fun apply(project: Project) { val ext = project.extensions.create("greeting", GreetingExtension::class.java) project.afterEvaluate { // depends on user-set ext values project.tasks.register("greet") { it.description = ext.message.get() } } } ``` then a naive ProjectBuilder test will find **no** `greet` task, because `afterEvaluate` never fired. ### Forcing evaluation To run the deferred code you must trigger evaluation, which is only exposed via an **internal** API: ```kotlin import org.gradle.api.internal.project.ProjectInternal (project as ProjectInternal).evaluate() ``` After this call, `afterEvaluate` blocks run and the deferred tasks appear. Caveats: - `ProjectInternal.evaluate()` is **not part of the public API** and can change between Gradle versions — it's a known-but-brittle technique. - Because you're poking internals, such tests are more fragile than equivalent TestKit tests. ## Other multi-project pitfalls - **`subprojects {}` / `allprojects {}`**: these register actions whose effect depends on evaluation order. In a real build, ordering and lifecycle guarantee they apply; with ProjectBuilder you may need to build children before/after configuring, and even then results can diverge. - **Cross-project task dependencies**: `dependsOn(project(":lib").tasks.named("jar"))` resolves references but won't execute; you're only checking wiring, not runtime ordering. - **No real settings phase**: ProjectBuilder doesn't run `settings.gradle`, so `include`/composite-build semantics aren't reproduced. ## Practical guidance Use ProjectBuilder hierarchies for **per-project configuration wiring** and simple parent/child relationships. The moment your logic depends on `afterEvaluate`, evaluation order, or cross-project execution, prefer **TestKit**, which runs the real lifecycle out-of-process and avoids internal APIs entirely. If you must test `afterEvaluate` wiring in-process for speed, isolate the `(project as ProjectInternal).evaluate()` call behind a small test helper so the brittleness is contained.

  • Why doesn't an afterEvaluate{} block run in a plain ProjectBuilder test?
    Because ProjectBuilder leaves the project unevaluated. afterEvaluate hooks fire during project evaluation, which a real build performs automatically but ProjectBuilder does not — you must call evaluate() yourself.
  • What's the risk of using ProjectInternal.evaluate() in tests?
    It's an internal, non-public API that can change or break between Gradle versions, making the test brittle. For afterEvaluate-dependent behaviour, TestKit is the more robust choice because it runs the real lifecycle without internal APIs.
  • Can you assert cross-project execution order with a ProjectBuilder hierarchy?
    No. You can wire dependsOn references between projects, but nothing executes, so ordering and outcomes aren't observable. Cross-project execution behaviour needs a real build via TestKit.

saying these in an interview costs you the question

  • Assuming afterEvaluate{} logic runs automatically in ProjectBuilder.
  • Treating ProjectInternal.evaluate() as a stable public API.
  • Expecting subprojects{}/allprojects{} and cross-project execution to behave exactly like a real multi-project build.

context