skip to content

Why is wiring extension providers into tasks (rather than capturing values via closures or eager reads) essential for configuration-cache compatibility and reliable up-to-date checks?

level: seniorimportance: should knowfreq 35%

answer

  1. config cache serializes task graph
  2. no Project/extension refs at execution
  3. wired provider = serializable + tracked
  4. closure over project = cache failure
  5. eager copy = untracked input, stale up-to-date

basics

~10 s

Provider wiring keeps task inputs as serializable, declared lazy values resolved at execution. Closures over the project/extension capture live objects the configuration cache can't serialize, and eager reads bypass input tracking, breaking up-to-date checks.

solid answer

~50 s

The configuration cache serializes the task graph after configuration and reuses it on later builds, skipping configuration entirely. For that to work, a task's state must be made of serializable, self-contained values — not references to the `Project`, the extension object, or closures that read them at execution. When you wire `task.input.set(extension.input)`, Gradle stores the resolved provider chain as part of the task's serializable state and resolves it at execution against captured inputs; nothing reaches back into the live build model. By contrast, a `@TaskAction` (or a `doLast` closure) that calls `project.extensions.getByType(...)` at execution captures the `Project`, which the configuration cache forbids. Eager reads cause a subtler bug: copying `extension.x.get()` into a plain field means the value isn't a tracked `@Input` provider, so Gradle can't detect when it changes — up-to-date checks and the build cache key go stale. Provider wiring makes inputs both *serializable* and *tracked*.

code

kotlin · 9 lines
kotlin
abstract class PrintTask : DefaultTask() {
    @get:Input abstract val message: Property<String>
    @TaskAction fun run() = println(message.get()) // reads only own property
}

// plugin wires provider -> property; action never touches project/extension
tasks.register("print", PrintTask::class.java) {
    it.message.set(ext.message) // serializable, tracked, config-cache safe
}

go deeper

for a junior

Know that tasks should read their own wired properties, not the project.

for a middle

Explain that closures over the project break the config cache and that eager copies aren't tracked inputs.

for a senior

Connect provider wiring to both serializability and input fingerprinting for up-to-date and build-cache correctness.

for a principal

Set authoring standards (annotated lazy inputs, no live-model access in actions) so the whole build is config-cache clean and cacheable.

## What the configuration cache requires The **configuration cache** persists the configured task graph and replays it on subsequent builds, skipping the configuration phase. To serialize a task, Gradle must turn its state into bytes — so every field that matters must be serializable and free of references to the live build model (`Project`, `Gradle`, `Configuration`, `Task` instances, scripts). Holding those at execution time is an error the cache reports. ## How provider wiring satisfies this When you do: ```kotlin task.message.set(extension.message) ``` you attach a `Provider` to the task's `Property`. Gradle knows how to serialize provider chains built from other properties and standard sources. At execution the provider is resolved from the *serialized* inputs, not by phoning back into the project. So the task is self-contained and cacheable. ## The two anti-patterns **1. Closures that capture the build model.** Reading config at execution: ```kotlin // BAD: captures Project, fails config cache doLast { println(project.extensions.getByType(Ext::class.java).message.get()) } ``` The lambda closes over `project`. The configuration cache can't serialize that, so the build errors (or silently behaves wrong if checks are off). **2. Eager reads into untracked fields.** Copying a resolved value: ```kotlin // BAD: value isn't a tracked @Input provider val msg = extension.message.get() task.doLast { println(msg) } ``` Here `msg` is a captured constant. It bypasses Gradle's input tracking: it's not an `@Input Property`, so Gradle doesn't know it's part of the task's fingerprint. **Up-to-date checks** and the **build cache key** are computed from declared inputs/outputs; an untracked captured value won't invalidate them when it changes, producing stale results. ## Why the lazy wiring fixes both A wired `@Input Property`: - is **serializable** as a provider chain → config-cache safe; - is a **declared input** → its value participates in up-to-date and build-cache fingerprints; - resolves at execution from serialized state → no live model access. ## Rule of thumb Expose task inputs as `@Input`/`@InputFiles` lazy properties, wire them from extension providers in the plugin, and let the `@TaskAction` read only `this`'s own properties — never `project` or the extension directly.

  • What goes wrong if a `doLast` block calls `project.extensions.getByType(...)` at execution?
    It captures the live `Project`, which the configuration cache cannot serialize, so the build fails the cache check (or behaves incorrectly). Read from the task's own wired properties instead.
  • Why does an eagerly captured value break up-to-date checks?
    It isn't a declared `@Input` provider, so Gradle's input fingerprint doesn't include it. When that value changes, the task still looks up-to-date and produces stale output.
  • Does provider wiring alone guarantee correct up-to-date behavior?
    Only if the property is annotated as a tracked input/output (`@Input`, `@InputFile`, etc.). Wiring plus correct annotations together make the value participate in the fingerprint.

saying these in an interview costs you the question

  • Suggesting the task action read the extension via `project.extensions` at execution.
  • Claiming eager value capture is fine as long as you 'read it once'.
  • Believing the configuration cache can serialize closures over `Project`.

context