skip to content

When would you choose a typed (or custom) task over an ad-hoc doLast task, and what do you lose by staying ad-hoc?

level: seniorimportance: should knowfreq 45%

answer

  1. inputs/outputs → up-to-date + cache
  2. @CacheableTask, @InputFiles, @OutputFile
  3. closures can't be cached/tested
  4. config cache rejects project capture
  5. promote when reused or has IO

basics

~10 s

Use 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 s

An **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
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() { /* read source, write output */ }
}

tasks.register<Checksum>("checksum") {
    source.from("src")
    output.set(layout.buildDirectory.file("checksum.txt"))
}

go deeper

for a junior

Recognize that typed tasks (Copy, Zip) come with behavior and ad-hoc tasks don't.

for a middle

Explain that declared inputs/outputs enable up-to-date checks and that ad-hoc closures lack them.

for a senior

Tie the choice to caching (@CacheableTask), testability, reuse, and configuration-cache safety; know the promotion triggers.

for a principal

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

context