skip to content

In a custom DefaultTask, what is the difference between code placed in the constructor and code placed in the @TaskAction method?

level: middleimportance: must knowfreq 55%

answer

  1. constructor = configuration time
  2. @TaskAction = execution time only
  3. cheap defaults/group/description in init
  4. file IO / tool calls in action
  5. config-time work breaks incrementality + config cache

basics

~10 s

Constructor code runs at configuration time (every build, whenever the task is realized). @TaskAction code runs at execution time, only when the task actually runs. Put the real work in @TaskAction, not the constructor.

solid answer

~40 s

A custom task class has two distinct timing contexts. The **constructor** (and any field initializers) executes at **configuration time** — when Gradle realizes the task while building the model, on essentially every invocation that touches it. The method annotated **@TaskAction** executes at **execution time** — only when the task is selected to run and is not up-to-date. So expensive or side-effecting work (reading/writing files, invoking tools, network calls) must live in `@TaskAction`; the constructor should only do cheap wiring such as setting `group`/`description` or convention defaults on properties. Doing real work in the constructor breaks the configuration/execution split, runs even when the task is skipped, and undermines incremental builds and the configuration cache. Service access for execution work should be injected (e.g. `WorkerExecutor`, `ExecOperations`) rather than reached eagerly in the constructor.

code

kotlin · 12 lines
kotlin
abstract class ReportTask : DefaultTask() {
    @get:Input abstract val title: Property<String>
    init {
        group = "reporting"
        title.convention("Untitled") // OK: cheap, config-time
    }
    @TaskAction
    fun generate() {
        // file/network/tool work belongs here
        logger.lifecycle("Report: ${title.get()}")
    }
}

go deeper

for a junior

Know that work goes in @TaskAction and the constructor is for setup, not the actual task work.

for a middle

Explain configuration-time vs execution-time and which code lands where, with concrete consequences for skipped tasks.

for a senior

Connect it to incremental builds and configuration-cache compatibility; show correct use of conventions and injected services.

for a principal

Mandate review rules forbidding config-time side effects in shared task types to keep large builds fast and config-cache-clean.

## The two phases that matter for a task Gradle builds run in phases: **initialization** → **configuration** → **execution**. For task authoring the key split is configuration vs execution. - **Configuration time**: Gradle constructs and configures task objects to build the task graph. A custom task's **constructor**, Kotlin property initializers, and the `register { ... }` configuration block all run here — and they run whenever the task is *realized*, which for a needed task is on every build. - **Execution time**: Gradle runs the selected tasks in dependency order, skipping up-to-date or cached ones. Only the **@TaskAction** body runs here. ## Why the distinction is load-bearing If you do real work in the constructor: - it runs even when the task is **skipped** (UP-TO-DATE, NO-SOURCE, or never in the graph); - it runs during `./gradlew help` or any unrelated invocation that realizes the task; - it can read state too early, before other plugins/config have applied; - it pollutes the configuration phase, hurting configuration-cache compatibility and configuration time. Real work belongs in `@TaskAction` so Gradle can decide *whether* to run it (incremental/up-to-date checks) and *when*. ## What the constructor is good for ```kotlin abstract class ReportTask : DefaultTask() { @get:Input abstract val title: Property<String> init { group = "reporting" description = "Generates a report" title.convention("Untitled") // cheap default } @TaskAction fun generate() { // file IO / tool invocation goes HERE logger.lifecycle("Report: ${title.get()}") } } ``` Setting `group`, `description`, and property **conventions** (lazy defaults) is appropriate constructor work because it is cheap and side-effect-free. ## Services for execution work If the action needs to fork processes or run work in parallel, inject the service via the constructor abstractly and *use* it in the action — for example `@Inject abstract fun getExecOperations(): ExecOperations`. You declare it in the constructor but invoke it at execution time. ## Common mistake Reading a file or calling an external process in `init { }` or a field initializer: it executes during configuration of unrelated builds, defeating laziness and incrementality.

  • What happens if you read a file in the task constructor?
    It executes at configuration time on every realizing build, even when the task is skipped or unrelated to the invocation, hurting incrementality and configuration-cache compatibility.
  • What kind of work is acceptable in the constructor/init block?
    Cheap, side-effect-free wiring: setting group/description and applying property conventions (lazy defaults).

saying these in an interview costs you the question

  • Saying the constructor runs only when the task executes.
  • Putting file IO or external process calls in init/field initializers.
  • Claiming there is no observable difference between the two locations.

context