When would you choose a typed (or custom) task over an ad-hoc doLast task, and what do you lose by staying ad-hoc?
answer
- inputs/outputs → up-to-date + cache
- @CacheableTask, @InputFiles, @OutputFile
- closures can't be cached/tested
- config cache rejects project capture
- promote when reused or has IO
basics
~10 sUse a typed/custom task when logic is reused, needs declared inputs/outputs for up-to-date checks and caching, or needs unit testing. Ad-hoc doLast tasks are fine for tiny one-off glue but get none of that.
solid answer
~50 sAn **ad-hoc** task (`DefaultTask` + `doLast`) is perfect for small, project-local glue. You should promote to a **typed** built-in task (`Copy`, `Zip`, `Exec`…) or a **custom `DefaultTask` subclass** once the logic: (1) repeats across projects/builds — a class is reusable, a closure isn't; (2) reads or writes files you want tracked — annotating `@InputFiles`/`@OutputDirectory` on a typed task enables **incremental/up-to-date** behavior and `@CacheableTask` build-cache reuse, which raw `doLast` closures don't get; (3) needs to be **unit-tested** in isolation; or (4) must be **config-cache compatible** — closures that capture `project` at execution time are flagged, whereas a typed task with properties and injected services is clean. The cost of staying ad-hoc is no input/output tracking (so it always re-runs), no caching, no reuse, and harder testing. The rule of thumb: ad-hoc for a few lines you'll never repeat; typed/custom the moment it has inputs, outputs, or a second caller.
code
kotlin · 17 lines@CacheableTask
abstract class Checksum : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val source: ConfigurableFileCollection
@get:OutputFile
abstract val output: RegularFileProperty
@TaskAction
fun run() { /* read source, write output */ }
}
tasks.register<Checksum>("checksum") {
source.from("src")
output.set(layout.buildDirectory.file("checksum.txt"))
}go deeper
Recognize that typed tasks (Copy, Zip) come with behavior and ad-hoc tasks don't.
Explain that declared inputs/outputs enable up-to-date checks and that ad-hoc closures lack them.
Tie the choice to caching (@CacheableTask), testability, reuse, and configuration-cache safety; know the promotion triggers.
Set org-wide policy: build logic with inputs/outputs lives in convention plugins as typed tasks; ad-hoc reserved for trivial local glue, to keep builds cacheable and config-cache-clean.
## The spectrum 1. **Ad-hoc** — `tasks.register("x") { doLast { ... } }`. No type, inline closure. 2. **Built-in typed** — `tasks.register<Copy>("x") { from(...); into(...) }`. Behavior + annotated inputs/outputs from Gradle. 3. **Custom typed** — your own `abstract class MyTask : DefaultTask()` with `@TaskAction` and annotated properties. ## What typed tasks give you ### Incremental build / up-to-date checks Gradle skips a task whose declared inputs and outputs are unchanged. That tracking comes from **input/output annotations** (`@Input`, `@InputFiles`, `@OutputFile`, `@OutputDirectory`, `@Internal`) on a task type's properties. A bare `doLast` closure declares nothing, so Gradle can't know what it reads or writes — such tasks tend to always run (or are wrongly marked up-to-date). ### Build cache Add `@CacheableTask` to a task type and Gradle can fetch outputs from the local/remote build cache when the inputs match — even across machines. Ad-hoc closures can't be cached safely. ### Testability and reuse A `DefaultTask` subclass is a class: you can unit-test it (e.g. with `ProjectBuilder` or by invoking the action) and reuse it in many builds via a plugin. A closure lives in one build script. ### Configuration-cache friendliness The **configuration cache** serializes the task graph. Closures that capture the `Project` and call it at execution time are rejected. Typed tasks expose lazy `Property`/`Provider` inputs and use injected services (`@Inject ExecOperations`, `FileSystemOperations`) instead of touching `project` at runtime — making them config-cache safe. ```kotlin @CacheableTask abstract class Checksum : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val source: ConfigurableFileCollection @get:OutputFile abstract val output: RegularFileProperty @TaskAction fun run() { /* compute, write output */ } } ``` ## When ad-hoc is still right Tiny, throwaway, project-specific steps with no meaningful inputs/outputs (printing info, a quick local convenience) — writing a class would be ceremony. The judgment call is recognizing the moment glue becomes infrastructure.
- Why does a bare doLast task usually re-run every build?It declares no inputs/outputs, so Gradle can't compute an up-to-date check and conservatively runs it.
- What annotation makes a task type eligible for the build cache?@CacheableTask, combined with properly annotated inputs (with path sensitivity) and outputs.
- Why are ad-hoc closures problematic for the configuration cache?They often capture the Project and call it at execution time, which the configuration cache forbids; typed tasks use lazy properties and injected services instead.
saying these in an interview costs you the question
- Claiming ad-hoc tasks can be build-cached
- Saying every task should be a custom class — tiny glue is fine ad-hoc
- Assuming inputs/outputs are tracked automatically without annotations