skip to content

How would you define a reusable custom task type in buildSrc and use it from multiple modules?

level: middleimportance: should knowfreq 35%

answer

  1. extend DefaultTask, abstract Properties
  2. @Input / @OutputFile annotations
  3. @TaskAction method
  4. @CacheableTask for build cache
  5. tasks.register<MyTask>(...) lazily

basics

~10 s

Write a class extending DefaultTask under buildSrc/src/main/kotlin with @Input/@OutputFile-annotated properties, then in any module use tasks.register<MyTask>("name") to register and configure it.

solid answer

~40 s

Put a Kotlin/Java class extending `DefaultTask` under `buildSrc/src/main/kotlin`. Annotate its inputs with `@Input`/`@InputFile`/`@InputDirectory` and its outputs with `@OutputFile`/`@OutputDirectory`, use lazy `Property<T>`/`RegularFileProperty` for configurability, and mark the action with `@TaskAction`. Because buildSrc is on every build-script classpath, the type is importable in any module's `build.gradle.kts`, where you call `tasks.register<MyTask>("generateThing") { ... }`. The annotated inputs/outputs make the task **up-to-date checkable** and, if you add `@CacheableTask`, **build-cache eligible** — so it only re-runs when its declared inputs change. Using the typed `register` (not `create`) keeps it lazy. This is the cleanest way to share a real, incremental, cacheable task across a multi-project build without publishing a plugin.

code

kotlin · 14 lines
kotlin
// buildSrc/src/main/kotlin/com/acme/GenerateVersionFile.kt
@CacheableTask
abstract class GenerateVersionFile : DefaultTask() {
    @get:Input abstract val version: Property<String>
    @get:OutputFile abstract val outputFile: RegularFileProperty
    @TaskAction fun run() =
        outputFile.get().asFile.writeText("version=${version.get()}\n")
}

// module/build.gradle.kts
tasks.register<GenerateVersionFile>("genVersion") {
    version.set(project.version.toString())
    outputFile.set(layout.buildDirectory.file("version.properties"))
}

go deeper

for a junior

Know you can write a DefaultTask subclass in buildSrc and register it in modules.

for a middle

Define one with proper @Input/@Output annotations and lazy Properties; register it with the typed register API.

for a senior

Add @CacheableTask, reason about up-to-date checks, path sensitivity, and provider-based dependency inference.

for a principal

Establish task-authoring standards (cacheability, input normalization) across shared build tooling.

## Why put a task type in buildSrc When the same custom work (codegen, license-header check, manifest stamping) is needed in several modules, you don't want to copy an ad-hoc `tasks.register("x") { doLast { ... } }` into each build file. Define the behavior **once** as a typed task in buildSrc and register instances per module. ## Anatomy of a custom task type ```kotlin // buildSrc/src/main/kotlin/com/acme/GenerateVersionFile.kt import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.* @CacheableTask abstract class GenerateVersionFile : DefaultTask() { @get:Input abstract val version: Property<String> @get:OutputFile abstract val outputFile: RegularFileProperty @TaskAction fun generate() { outputFile.get().asFile.writeText("version=${version.get()}\n") } } ``` Key ingredients: - **`abstract` class + `abstract val` Properties:** Gradle generates the implementation; lazy `Property<T>`/`RegularFileProperty` defer value resolution to execution time and integrate with the Provider API. - **Input/Output annotations:** `@Input` (a scalar), `@InputFile`/`@InputDirectory` (with `@PathSensitive`), `@OutputFile`/`@OutputDirectory`. These declare the task's contract so Gradle can compute up-to-date checks. - **`@TaskAction`:** the method to run. - **`@CacheableTask`:** opts the task into the build cache (so outputs can be reused across machines/checkouts when inputs match). ## Using it from modules ```kotlin // any module/build.gradle.kts import com.acme.GenerateVersionFile tasks.register<GenerateVersionFile>("generateVersionFile") { version.set(project.version.toString()) outputFile.set(layout.buildDirectory.file("generated/version.properties")) } ``` No import wiring beyond the Kotlin `import` — buildSrc's output is already on the classpath. Prefer `tasks.register` (lazy, created only if needed) over `tasks.create` (eager). ## Wiring into the lifecycle Usually you also wire the output into a consumer, e.g. add the generated dir to a source set or make `processResources` depend on it. Using `Provider`/`Property` lets Gradle infer task dependencies automatically when one task's output property feeds another's input property. ## Combine with conventions Often the registration itself is moved into a convention plugin in buildSrc, so a module gets the task simply by applying `id("acme.codegen-conventions")`. The task *type* lives in buildSrc src; the *registration* lives in a convention plugin — both in buildSrc.

  • Why use tasks.register instead of tasks.create?
    register is part of the lazy configuration API — the task is configured only if it ends up in the task graph, avoiding eager configuration cost. create is eager and discouraged.
  • What do the @Input/@OutputFile annotations buy you?
    They declare the task's inputs and outputs so Gradle can do up-to-date checks (skip when unchanged) and, with @CacheableTask, store/reuse outputs from the build cache.

saying these in an interview costs you the question

  • Using doLast {} closures duplicated across modules instead of a real typed task.
  • Omitting @Input/@Output annotations, leaving the task non-incremental and always out-of-date.
  • Using tasks.create (eager) instead of tasks.register (lazy).

context