Writing plugins and lazy configuration

Gradle · Expert · 15 min

Convention and tooling plugins are how large organizations standardize builds; the lazy API is what makes them cache-friendly.

A binary plugin is a class implementing Plugin<Project>, usually written in Kotlin in build-logic (an included build) or a standalone repository published to a plugin repository. Its apply(project) method registers tasks, applies other plugins and creates an extension that users configure.

Gradle's modern API is lazy:

  • Use Property<T>, ListProperty<T>, RegularFileProperty and DirectoryProperty for extension and task fields, with abstract val so Gradle injects managed instances.
  • Wire values with providers instead of reading them eagerly: task.outputDir.set(ext.outputDir) links the two; the value is computed only when the task runs. Transform with map { } and flatMap { }; project.provider { } wraps arbitrary code.
  • Register tasks with tasks.register, configure others with tasks.named, and avoid project.afterEvaluate.
  • Never call project or Project services from @TaskAction; inject services (ExecOperations, FileSystemOperations, ObjectFactory) via @Inject instead. This keeps tasks compatible with the configuration cache.

Annotate task properties (@Input, @InputFiles, @OutputDirectory, @Internal, @Nested) so up-to-date checks and caching work; mark the task @CacheableTask when outputs are deterministic. Work can be parallelized with the Worker API.

Test plugins with Gradle TestKit (GradleRunner), which runs a real build in a temp directory, and declare plugin ids in the gradlePlugin { plugins { } } block of the java-gradle-plugin so they can be applied by id. Publish with the com.gradle.plugin-publish plugin.

Example

// build-logic/src/main/kotlin/com/acme/GreetingPlugin.kt
package com.acme

import org.gradle.api.*
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*

abstract class GreetingExtension { abstract val message: Property<String> }

@CacheableTask
abstract class GreetTask : DefaultTask() {
    @get:Input abstract val message: Property<String>
    @get:OutputFile abstract val output: RegularFileProperty
    @TaskAction fun run() = output.get().asFile.writeText(message.get())
}
class GreetingPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val ext = project.extensions.create("greeting", GreetingExtension::class.java)
        ext.message.convention("Hello")                       // default, overridable
        project.tasks.register("greet", GreetTask::class.java) {
            it.message.set(ext.message.map { m -> "$m from ${project.name}" })   // lazy wiring
            it.output.set(project.layout.buildDirectory.file("greeting.txt"))
        }
    }
}
// consumer build.gradle.kts:  plugins { id("com.acme.greeting") }   greeting { message = "Hi" }
// $ ./gradlew greet && cat build/greeting.txt   -> Hi from app

Remember

  • A plugin class registers tasks and creates an extension in apply()
  • Use Property/Provider types and map/flatMap for lazy wiring
  • Annotate task inputs/outputs; inject services instead of using Project in actions
  • Test with TestKit's GradleRunner

Watch out for

  • Reading extension values eagerly in apply(), before the user's build script has set them.

Check yourself

  1. Why wire task.message.set(ext.message) instead of task.message.set(ext.message.get()) inside apply()?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.gradle.org/current/userguide/implementing_gradle_plugins_binary.html