skip to content

How would you design a layered set of convention plugins for a large multi-module build, and what pitfalls would you avoid?

level: seniorimportance: should knowfreq 40%

answer

  1. base → library/application → specialized
  2. single responsibility per plugin
  3. apply lower layers by id
  4. version catalog shared with build-logic
  5. no module-name branching, stay lazy

basics

~10 s

Create 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 s

Design 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
kotlin
// 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

for a junior

Know that you can have multiple convention plugins and apply them per module.

for a middle

Describe a base/library/application split and applying lower layers by id.

for a senior

Design the full hierarchy, manage versions via catalog, and avoid the named pitfalls (laziness, module branching, cycles).

for a principal

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.

context