How would you design a layered set of convention plugins for a large multi-module build, and what pitfalls would you avoid?
answer
- base → library/application → specialized
- single responsibility per plugin
- apply lower layers by id
- version catalog shared with build-logic
- no module-name branching, stay lazy
basics
~10 sCreate small, single-responsibility convention plugins (base, library, application, test, publishing) and compose them by applying lower-layer ones inside higher-layer ones. Avoid one monolithic plugin and avoid leaking module-specific logic into shared conventions.
solid answer
~50 sDesign convention plugins as a **layered, composable hierarchy** rather than one god-plugin. A typical layout: a `*.base-conventions` plugin (repositories, toolchain, shared test setup); then `*.library-conventions` and `*.application-conventions` that each apply the base by id and add their specifics; plus orthogonal plugins like `*.publishing-conventions` or `*.spring-conventions`. Each plugin has a **single responsibility**, so modules opt into exactly what they need by listing a couple of ids in `plugins {}`. Keep them in `build-logic` as an included build, drive plugin versions through a **version catalog** shared with the main build, and keep convention plugins **free of module-specific or environment-specific branching** (`if (project.name == ...)` is a smell). Pitfalls: monolithic plugins that force every module to take everything; circular plugin dependencies; reading mutable project state at apply time instead of using lazy `Provider`/`Property`; and embedding secrets or hardcoded versions instead of catalog references.
code
kotlin · 16 lines// com.acme.base-conventions.gradle.kts
plugins { `java` }
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
repositories { mavenCentral() }
// com.acme.library-conventions.gradle.kts — composes base
plugins {
id("com.acme.base-conventions")
`java-library`
}
// a module
plugins {
id("com.acme.library-conventions")
id("com.acme.publishing-conventions")
}go deeper
Know that you can have multiple convention plugins and apply them per module.
Describe a base/library/application split and applying lower layers by id.
Design the full hierarchy, manage versions via catalog, and avoid the named pitfalls (laziness, module branching, cycles).
Treat conventions as governed platform code — versioned, TestKit-covered, shared across repos, owned by a build-platform team with a deprecation policy.
## Goal: composable build conventions In a large build, modules fall into a few archetypes — internal library, runnable service, test-fixtures-only, BOM/platform, published artifact. The right design gives each archetype a **thin, named convention plugin** it can apply, built by composing smaller building blocks. ## A layered hierarchy ``` build-logic/src/main/kotlin/ com.acme.base-conventions.gradle.kts // toolchain, repos, common test deps com.acme.library-conventions.gradle.kts // java-library + base com.acme.application-conventions.gradle.kts // application + base com.acme.spring-conventions.gradle.kts // spring-boot + library com.acme.publishing-conventions.gradle.kts // maven-publish wiring ``` Higher layers **apply lower layers by id**: ```kotlin // com.acme.library-conventions.gradle.kts plugins { id("com.acme.base-conventions") `java-library` } ``` Gradle resolves the apply order. A module then writes: ```kotlin plugins { id("com.acme.library-conventions") id("com.acme.publishing-conventions") } ``` ## Single responsibility Each convention plugin should answer one question ("what makes a publishable library here?"). This keeps them readable and lets archetypes mix-and-match. A monolithic `com.acme.conventions` that configures publishing, Spring, Docker, and code-quality forces every consumer to drag in everything and makes the plugin a merge-conflict magnet. ## Version management Share a **version catalog** (`gradle/libs.versions.toml`) between the main build and `build-logic` (point `build-logic/settings.gradle.kts` at the same catalog file). Reference plugin markers and dependency versions from the catalog so there is one source of truth. Hardcoding versions inside convention plugins is the classic drift bug. ## Laziness and correctness - Configure with **lazy APIs**: `tasks.register`, `Property`/`Provider`, `layout.buildDirectory`, `providers.gradleProperty(...)`. Reading eager values (`project.version` as a String, environment at apply time) breaks the configuration cache and project isolation. - Don't branch on module identity inside a shared plugin — that couples the convention to specific modules. If a module truly differs, give it its own convention plugin or an extension knob. - Avoid **circular plugin dependencies** (A applies B applies A) — Gradle will fail or behave unpredictably. ## Testing the conventions Convention plugins are real build logic, so back them with TestKit functional tests in `build-logic` (a separate leaf), and keep their behavior verified as the build evolves. ## Pitfalls checklist 1. God-plugin that does everything. 2. Hardcoded versions / secrets instead of catalog + properties. 3. Eager project-state reads breaking config cache. 4. `if (project.name == ...)` module-specific branching. 5. Circular convention-plugin application. 6. Re-implementing buildSrc auto-application semantics instead of using included build wiring (placement is a separate topic).
- Why is `if (project.name == "payments") { ... }` inside a shared convention plugin a smell?It couples a generic convention to a specific module, breaks the single-responsibility/composition model, and tends to grow. Prefer giving that module its own convention plugin or exposing a configurable extension property.
- How do you keep plugin versions consistent between build-logic and the main build?Share one version catalog (`gradle/libs.versions.toml`) by pointing build-logic's settings at the same TOML file, and reference plugin markers/versions from it everywhere — one source of truth, no drift.
- Why prefer `tasks.register` over `tasks.create` in convention plugins?`register` is lazy — the task is only configured if needed — which keeps configuration time low and is friendly to the configuration cache; `create` is eager and configures the task immediately.
Like composing small mixins/traits instead of one fat base class — modules inherit exactly the behaviors they opt into.
saying these in an interview costs you the question
- Proposing a single monolithic convention plugin for everything.
- Hardcoding versions inside convention plugins instead of a catalog.
- Eagerly reading project state, breaking the configuration cache.
- Module-name branching inside shared conventions.