Chapter 27Lesson 01~250 minutes

Custom Gradle Plugins, Build Logic, Precompiled Script Plugins, TestKit, and Plugin Development: Concepts, Architecture, and Mental Model

Treat shared Gradle build logic as software: define a stable plugin contract, keep configuration lazy, expose typed extension properties, use public APIs, test real consuming builds with TestKit, and publish only when the compatibility boundary is understood.

Gradle 9.7.1PluginConvention pluginsTestKitPublic API

Learning objectives

  • Explain binary, precompiled-script, and convention plugins without treating them as interchangeable files.
  • Separate plugin ID, implementation class, extension model, task implementation, marker metadata, and consumer build state.
  • Use lazy public Gradle APIs and understand why internal APIs are an upgrade liability.
  • Explain buildSrc versus a build-logic included build and why current Gradle guidance prefers the latter at scale.
  • Describe what TestKit proves that unit tests alone cannot prove.
  • Design a small supported Gradle/JDK compatibility matrix for shared build logic.

Version baseline. This chapter is pinned to Gradle 9.7.1 (released 2026-08-19) with JDK 21 as the lab runtime and Java 17 bytecode target for plugin classes. The Gradle Wrapper remains the authoritative entry point.

1. Why copy-pasted build scripts become platform risk

Chapter 18 introduced convention plugins as a way to centralize policy. Chapter 27 goes one level deeper: you now own the plugin implementation itself. A copied subprojects {} block can drift quietly across repositories. A plugin gives the policy a name, a source tree, tests, version history, review boundary, and—in the binary-plugin case—a publishable artifact.

The risk is broader than convenience. A plugin can apply other plugins, create repositories, configure compilation, alter test tasks, access credentials, publish artifacts, and influence every CI job that evaluates the build. Shared build logic is therefore platform code, not a harmless helper script.

2. Mental model: source → plugin contract → consuming build

Build-logic flow
flowchart TD
  A[Plugin source
Java/Kotlin/precompiled DSL] --> B[Plugin build
java-gradle-plugin]
  B --> C[Plugin JAR + descriptor]
  B --> D[TestKit temp build]
  C --> E[Plugin ID / marker]
  E --> F[Consumer plugins block]
  F --> G[Extension + lazy tasks]
  G --> H[Task graph / outputs]
  D --> I[Functional evidence]
  I --> J[Compatibility policy]

The source code is compiled by a plugin build. The plugin ID is the consumer-facing name; for a binary plugin, the implementation class is Gradle's entry point behind that name. Applying the plugin mutates the consumer's model during configuration—ideally by registering lazy tasks and wiring managed properties, not by executing work. TestKit creates another Gradle build in a disposable directory and exercises that public contract end to end.

3. Define the state stores before editing them

Object / state Role Observable evidence Compatibility / trust question
Plugin ID Stable name used by the plugins {} DSL. gradlePlugin {}, generated descriptor, marker coordinates. Can consumers keep using this ID across implementation refactors?
Implementation class Binary entry point implementing Plugin<Project>. JAR class + META-INF/gradle-plugins/<id>.properties. Does it depend only on supported public Gradle APIs?
Extension Declarative user-facing configuration model. Typed Property<T> values configured in consuming build. Can defaults evolve without breaking existing builds?
TaskProvider Lazy handle to plugin-provided work. Task appears in tasks; realized only when needed. Does plugin configuration avoid eagerly creating task graphs?
build-logic included build Independent Gradle build that supplies repository-owned plugins. pluginManagement { includeBuild("build-logic") }. Is its toolchain/repository state independently reviewable?
Precompiled script plugin Compiled Gradle DSL script commonly used for internal conventions. src/main/kotlin/*.gradle.kts; ID derived from filename/package. Is the convention internal enough that a script-based plugin remains appropriate?
TestKit build Disposable real Gradle build used by functional tests. Temp directory + GradleRunner + task outcome/output assertions. Does the test depend on developer home, credentials, network, or mutable global state?
Plugin marker artifact Maps plugin ID/version to the implementation module for repository resolution. plugin.id:plugin.id.gradle.plugin:version POM/module. Can a clean consumer resolve exactly the intended implementation artifact?

4. Binary, precompiled script, convention, and script plugins

Convention plugin describes purpose: it codifies defaults/standards for other projects. A convention plugin can be implemented as a precompiled script plugin or as a binary plugin. A precompiled script plugin is a .gradle.kts/.gradle file compiled into a real plugin; its ID is derived from its filename/package. A binary plugin is a class implementing Plugin<Project> and is the stronger boundary when you need richer code organization, explicit API types, independent versioning, or publication.

Form Good fit Weakness / boundary
Precompiled Kotlin script Repository-internal Java/test conventions, familiar DSL, low boilerplate. Plugin ID is filename/package-derived; publication is not the preferred long-term sharing model.
Binary Java/Kotlin plugin Reusable platform plugin, typed API, tests, explicit implementation class, publishable JAR. More source structure and compatibility responsibility.
Plain script / apply-from Small throwaway experiment. Current Gradle guidance does not recommend it for maintainable production build logic.

5. Plugin ID is public identity; implementation class is an implementation detail

The consumer should know dev.academy.policy, not dev.academy.buildlogic.AcademyPolicyPlugin. The java-gradle-plugin plugin binds those two in gradlePlugin {}, generates a descriptor in the JAR, validates plugin metadata, and—when publishing is enabled—configures marker publications. That separation lets you refactor classes without changing the plugin ID.

gradlePlugin {
    plugins {
        create("academyPolicy") {
            id = "dev.academy.policy"
            implementationClass = "dev.academy.buildlogic.AcademyPolicyPlugin"
        }
    }
}

6. Extensions declare intent; tasks perform work

An extension is the consumer-facing configuration model. Managed types such as Property<String> and RegularFileProperty support lazy wiring. The plugin should connect extension providers to task inputs/outputs; it should not call get() early merely to copy values into mutable fields.

AcademyPolicyExtension extension = project.getExtensions().create(
    "academyPolicy", AcademyPolicyExtension.class
);
extension.getBanner().convention("reviewed");

project.getTasks().register("policyReport", PolicyReportTask.class, task -> {
    task.getBanner().set(extension.getBanner());
    task.getOutputFile().set(extension.getReportFile());
});

The arrow here is provider-to-property wiring. If the consumer later overrides academyPolicy.banner, the task sees that value when required. That is more reliable than eagerly reading the extension during plugin application.

7. buildSrc versus a build-logic included build

buildSrc is convenient and Gradle treats it as special build logic automatically available to build scripts. Current Gradle best-practice guidance, however, favors a dedicated included build such as build-logic for scalable shared logic. An included build is an ordinary Gradle build with its own settings, dependencies, tests, repository declarations, and task graph; changes can have a narrower invalidation boundary and the build can be developed independently.

Location Resolution / lifecycle Use when
buildSrc Automatically recognized and injected into the main build classpath. Rapid prototype or small build where convenience outweighs isolation.
build-logic included build Explicitly included, normally through pluginManagement { includeBuild(...) } for plugins. Preferred scalable repository-owned convention/plugin code.
Published plugin artifact Resolved through configured plugin repository and marker metadata. Multiple repositories/teams require independent release cadence and semantic versioning.

8. What TestKit adds to the testing pyramid

A unit test can instantiate a class or use ProjectBuilder, but it does not prove that the plugins {} DSL resolves your ID, that a real task graph executes, or that configuration cache behavior works in a real build. TestKit's GradleRunner executes an actual Gradle build in a directory you control. With java-gradle-plugin, withPluginClasspath() uses generated plugin-under-test-metadata.properties to inject the plugin implementation into the test build.

9. Compatibility is a matrix, not “works on my Gradle”

A plugin's contract crosses at least three version axes: Gradle version, JVM used to run Gradle, and the bytecode/API level of the plugin JAR. If the plugin also configures Java/Kotlin compilation, that is a fourth downstream axis. Record a minimum supported Gradle version, supported runtime JDKs, the baseline used for development, and a tested upgrade policy. TestKit can select another Gradle version with withGradleVersion(...); that distribution may need to be downloaded if it is not already available.

10. Read-only inspection before plugin changes

Before editing build logic, capture tool identity and current model evidence. These commands do not mutate normal user caches when the lab uses an isolated GRADLE_USER_HOME.

export GRADLE_USER_HOME="$PWD/.lab-gradle-home"
./gradlew --version
java -version
./gradlew projects
./gradlew tasks --all
./gradlew -p build-logic tasks --all

After a plugin build exists, inspect the JAR descriptor and generated metadata rather than assuming the ID/class mapping:

./gradlew -p build-logic jar
jar tf build-logic/build/libs/academy-build-logic-1.0.0.jar \
  | grep 'META-INF/gradle-plugins'
./gradlew -p build-logic validatePlugins

11. Why this matters in DevOps

Shared build logic can become a fleet-wide control plane. A bad plugin upgrade can disable tests, alter repositories, change artifact metadata, or leak secrets across many pipelines at once. Treat plugin source like application/platform source: code review, semantic versioning when published, functional tests, compatibility lanes, dependency review, wrapper/JDK pinning, and auditable release notes.

Knowledge check

What is the difference between a convention plugin and a precompiled script plugin?

Why should consumers depend on a plugin ID rather than an implementation class?

What does withPluginClasspath() prove or enable?

Why is an included build often preferred to buildSrc?

Why is importing org.gradle.api.internal.* risky?

Official references and version notes

Version-sensitive behavior was rechecked against current Gradle primary documentation on 2026-08-24. Mandatory work remains local/free: a supported JDK, the verified project Wrapper, a disposable workspace, and a small JUnit dependency for TestKit tests. Publishing to the Gradle Plugin Portal, public Maven repositories, paid CI, and external analytics are optional only.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.