You're modernizing an old custom task that uses plain String/File fields. How do you migrate it to Property/Provider, and what pitfalls do you watch for?
answer
- abstract val ... : Property<T> (managed)
- annotate @Input/@OutputFile
- defaults -> convention()
- read with get() in @TaskAction
- watch eager get() in configuration
basics
~10 sReplace eager fields with Property<T> declared as abstract managed properties or created via ObjectFactory, annotate inputs/outputs, set defaults with convention(), and read values at execution (inside the action) instead of at configuration time.
solid answer
~40 sMigrating an eager task means turning each configurable field into a lazy `Property<T>` and each computed output into a `Provider<T>`. Concretely: declare inputs as `abstract val foo: Property<String>` (managed) or build them with `project.objects.property(...)`, annotate them (`@get:Input`, `@get:OutputFile` etc.), move defaults from constructor assignments to `convention(...)`, and **read values inside the task action (`@TaskAction`/`doLast`) with `get()`**, never during configuration. Wire dependencies by setting an input `Property` from another task's output `Provider` so Gradle infers `dependsOn` automatically. The big pitfalls: (1) calling `get()` during configuration, which re-introduces eager resolution; (2) leaving a default as a field assignment so it can't be lazily overridden; (3) breaking up-to-date checking by not annotating inputs/outputs; and (4) accidentally finalizing a property too early. Done right, the task becomes configuration-cache compatible and avoids configuration-time cost.
code
kotlin · 11 linesabstract class Stamp : DefaultTask() {
@get:Input abstract val version: Property<String>
@get:OutputFile abstract val outFile: RegularFileProperty
@TaskAction fun run() =
outFile.get().asFile.writeText(version.get()) // resolved lazily at execution
}
tasks.register<Stamp>("stamp") {
version.convention("0.0.0")
outFile.convention(layout.buildDirectory.file("v.txt"))
}go deeper
Recognize that fields should become Property<T> read at execution time.
Walk the checklist: managed abstract properties, annotations, convention defaults, read in @TaskAction.
Call out the eager-get regression, dependency inference via wiring, and config-cache/up-to-date implications; mention finalizeValue.
Plan a phased migration across many custom tasks/plugins, gate config-cache enablement on it, and set review standards to prevent eager-resolution regressions.
## Before: an eager task ```kotlin open class OldStamp : DefaultTask() { var version: String = "0.0.0" // eager field var outFile: File = File("build/v.txt") // eager field @TaskAction fun run() = outFile.writeText(version) } ``` Problems: defaults are computed eagerly, no `@Input`/`@OutputFile` annotations (so up-to-date checks are weak), and you can't lazily wire `version` from another task. ## After: lazy + managed ```kotlin abstract class Stamp : DefaultTask() { @get:Input abstract val version: Property<String> @get:OutputFile abstract val outFile: RegularFileProperty @TaskAction fun run() { outFile.get().asFile.writeText(version.get()) // read at execution } } ``` Register and configure lazily: ```kotlin tasks.register<Stamp>("stamp") { version.convention("0.0.0") // lazy default outFile.convention(layout.buildDirectory.file("v.txt")) version.set(provider { computeVersion() }) // wired lazily } ``` ## Step-by-step migration checklist 1. **Make the task `abstract`** and convert fields to `abstract val ...: Property<T>` (managed). For files use `RegularFileProperty`/`DirectoryProperty` (separate leaf, but the same idea). 2. **Annotate** each property: `@get:Input`, `@get:InputFile`, `@get:OutputFile`, `@get:Internal`, etc. This restores reliable up-to-date/incremental behavior. 3. **Move defaults** from constructor/field assignment to `convention(...)`. 4. **Read at execution only** — replace any configuration-time computation with a `get()` inside `@TaskAction`. 5. **Wire dependencies** by `set(otherTask.flatMap { it.output })` so Gradle infers task ordering. ## Pitfalls - **Eager get() in configuration** — the single most common regression; it silently de-lazifies the task and can break the configuration cache. - **Defaults as fields** — they bypass the convention/override mechanism and aren't lazily wireable. - **Missing annotations** — without `@Input`/`@Output`, Gradle can't fingerprint inputs, hurting incremental builds and caching. - **Calling `.finalizeValue()`/`.disallowChanges()` too early** — useful to lock a property, but if done during configuration before consumers set it, you'll get errors. ## Payoff The migrated task participates correctly in dependency inference, up-to-date checks, and the configuration cache, and stops paying configuration-time cost for values it may not use.
- What's the most common regression when migrating, and how do you catch it?Calling get() during configuration instead of execution, which silently de-lazifies the task. Catch it by reviewing for get()/getOrElse() outside task actions and by enabling the configuration cache, which will fail or warn on eager resolution of certain providers.
- Why annotate the new properties with @Input/@OutputFile?Annotations tell Gradle which properties are inputs vs outputs so it can fingerprint them for up-to-date checks, incremental builds, and the build cache. Without them, Gradle can't reliably decide whether the task is up to date.
- How do you lock a property so consumers can't change it after a point?Call finalizeValue() (or disallowChanges()) once configuration that should set it is done; further set() calls then fail. Do it late enough that legitimate consumers have configured the value first.
saying these in an interview costs you the question
- Reading provider values in the configuration block during migration.
- Keeping defaults as field assignments instead of convention().
- Omitting @Input/@Output annotations and wondering why caching/up-to-date breaks.