skip to content

Why must configuration-time values be captured through Provider/Property and lazy task inputs rather than eagerly, for the task graph to serialize correctly?

level: middleimportance: should knowfreq 45%

answer

  1. graph must serialize → state must be serializable
  2. Provider/Property = serializable value holders
  3. don't capture Project/Configuration/Task in doLast
  4. read into local val before the action
  5. execution runs without a live Project on a hit

basics

~20 s

Because the configured task state must be serializable. Capturing values lazily through Provider/Property and declared task inputs lets Gradle store and restore them. Capturing a Project or other live objects in a closure makes the graph unserializable.

solid answer

~50 s

To serialize the task graph, Gradle must serialize each task's *configured state*. That means a task's fields and the values its actions reference have to be serializable. The lazy configuration model — `Property<T>`, `Provider<T>`, `ConfigurableFileCollection`, and declared `@Input`/`@InputFiles`/`@OutputFile` properties — exists precisely so that values are computed lazily and stored as well-defined, serializable holders rather than as captured live references. The classic failure is a task action (`doLast { ... }`) that closes over `project`, a `Configuration`, `Task`, or other non-serializable build-model object: at store time Gradle cannot serialize it and reports a configuration cache problem. The fix is to read the needed value into a local `val` (often via a provider) *before* the action and reference only that local. This makes the action's captured state a plain serializable value, so the graph round-trips and execution at deserialization time never touches the build model.

code

kotlin · 10 lines
kotlin
tasks.register("writeVersion") {
    // resolve during configuration into serializable locals
    val ver = providers.gradleProperty("appVersion").orElse("0.0.0")
    val out = layout.buildDirectory.file("version.txt")
    outputs.file(out)
    doLast {
        // only serializable values are captured
        out.get().asFile.writeText(ver.get())
    }
}

go deeper

for a junior

Know that you shouldn't reference project inside a task action and that providers help.

for a middle

Explain the serialize/deserialize round-trip and demonstrate the read-into-local fix with Provider/Property.

for a senior

Connect lazy configuration, declared inputs/outputs, and serializability; reason about why execution can't touch the model.

for a principal

Drive plugin authoring standards (provider-based APIs, no eager model capture) so first-party plugins stay cache-correct across the org.

## The constraint: the graph must round-trip The configuration cache works by **serializing** the task graph at the end of configuration and **deserializing** it later. For that to succeed, everything reachable from a task's configured state must be serializable — and on deserialization, execution must run *without* a live `Project` or configuration model present. ## Why lazy providers exist Gradle's lazy configuration APIs give you serializable value holders: - `Property<T>` / `Provider<T>` — a value computed on demand; Gradle stores the resolved value (or the provider chain) rather than a live object graph. - `ConfigurableFileCollection` and `RegularFileProperty`/`DirectoryProperty` — lazily resolved file inputs/outputs. - Declared `@Input`, `@InputFiles`, `@OutputFile` properties — the task's contract, which Gradle already knows how to snapshot. Using these means the configured state is *data*, not live wiring, so it serializes cleanly. ## The anti-pattern ```kotlin // BAD: action captures `project` -> not serializable tasks.register("bad") { doLast { println(project.version) // captures Project println(configurations["runtimeClasspath"].files) // captures live model } } ``` At store time Gradle emits a problem: *cannot serialize object of type Project / Configuration*. Execution-time access to the project model is also forbidden, because on a cache hit the model doesn't exist. ## The fix: read before, capture locals ```kotlin // GOOD: resolve to serializable values during configuration tasks.register("good") { val ver = project.version.toString() // plain String val classpath = configurations["runtimeClasspath"] // resolve to FileCollection .incoming.files inputs.files(classpath) doLast { println(ver) // captured local String println(classpath.files) // captured FileCollection } } ``` Now the action only closes over a `String` and a resolved `FileCollection`, both serializable, so the graph round-trips. ## Mental rule > Configuration time reads the live model; execution time uses only serializable values captured during configuration. Providers/Properties are the bridge. This is also why injected services and shared state are exposed as serializable references (e.g. build services injected via a `Property<MyService>`) rather than captured directly.

  • Why can't a task action reference `project` at execution time under the configuration cache?
    On a cache hit Gradle deserializes the task graph and executes without re-running configuration, so no live `Project` model exists. Actions must rely only on values captured during configuration.
  • How do you pass a resolved dependency classpath into a task in a cache-compatible way?
    Resolve `configurations[name].incoming.files` (a `FileCollection`) during configuration, declare it with `inputs.files(...)`, capture it in a local `val`, and reference that local inside the action.

saying these in an interview costs you the question

  • Capturing `project`, `Configuration`, `Task`, or `Gradle` inside `doLast`/`doFirst`.
  • Calling `project.version` or `configurations[...]` at execution time.
  • Assuming any closure is fine — only serializable captured state round-trips.

context