Design a plugin extension that lets users aggregate values from multiple build-script blocks into a collection consumed by a task. How do collection properties make this clean?
answer
- extension owns lazy collection property
- helper methods wrap add/put
- convention for defaults
- task.set(ext.property) — lazy wiring, no afterEvaluate
- order-independent multi-block aggregation
basics
~20 sExpose a ListProperty/MapProperty on the extension, give consumers add/put helper methods, set sensible conventions, and wire the task input to the extension property. Each build block appends lazily; the task resolves the aggregate at execution.
solid answer
~40 sModel the extension with a managed collection property — e.g. abstract val plugins: ListProperty<String> — and expose ergonomic helpers (fun plugin(id: String) = plugins.add(id)) so users contribute from any block in any order. Provide a convention for defaults. Then wire the task's @Input to the extension property (task.ids.set(extension.plugins)) or, better, register the task in an afterEvaluate-free way by mapping the provider directly. Because the property accumulates lazily, multiple configuration blocks, other plugins, and convention defaults all compose without ordering rules, and the task automatically depends on any producer providers contributed. This is the idiomatic Gradle aggregation pattern: the extension owns the lazy collection, helper methods make add/put readable, conventions supply defaults, and the task consumes the resolved aggregate at fingerprint time — fully configuration-cache friendly.
code
kotlin · 14 linesabstract class MyToolExtension {
abstract val modules: ListProperty<String>
fun module(id: String) { modules.add(id) }
}
class MyToolPlugin : Plugin<Project> {
override fun apply(project: Project) {
val ext = project.extensions.create("myTool", MyToolExtension::class.java)
ext.modules.convention(listOf("core"))
project.tasks.register("bundle", BundleTask::class.java) {
it.modules.set(ext.modules) // lazy, resolved at fingerprint time
}
}
}go deeper
Know that an extension can expose a ListProperty and a helper that calls add.
Wire the task input to the extension property with set(provider) and add a convention default.
Explain order independence, no-afterEvaluate laziness, automatic dependencies, and CC safety; cite eager-read pitfalls.
Standardize this aggregation idiom across the org's plugins so extensions compose predictably and stay configuration-cache compatible.
## The aggregation problem You want users to write: ```kotlin myTool { module("auth") module("billing") property("region", "eu") } // another plugin or block also contributes myTool { module("audit") } ``` ...and have a task see the union of all contributions, regardless of block order. ## Extension design Declare a managed collection property and friendly mutator methods: ```kotlin abstract class MyToolExtension { abstract val modules: ListProperty<String> abstract val props: MapProperty<String, String> fun module(id: String) { modules.add(id) } fun property(k: String, v: String) { props.put(k, v) } } ``` Register it and set conventions in the plugin's `apply`: ```kotlin class MyToolPlugin : Plugin<Project> { override fun apply(project: Project) { val ext = project.extensions.create("myTool", MyToolExtension::class.java) ext.modules.convention(listOf("core")) project.tasks.register("bundle", BundleTask::class.java) { it.modules.set(ext.modules) // lazy wiring, no afterEvaluate it.props.set(ext.props) } } } ``` ## Why collection properties make this clean - **Order independence** — every `module(...)` call is a deferred `add`; the task resolves the aggregate later, so block ordering and multi-plugin contributions don't matter. - **No `afterEvaluate`** — because wiring is lazy (`task.set(ext.property)`), you don't need to defer reads to `afterEvaluate`; the property is resolved at fingerprint time. - **Defaults via convention** — `convention(...)` supplies overridable defaults. - **Automatic dependencies** — if a contributed value is a provider from another task's output, the consuming task depends on it automatically. - **Configuration-cache safe** — the recipe serializes cleanly as long as contributors avoid capturing `Project`/`Task`. ## Pitfalls - Resolving `ext.modules.get()` inside `apply` (eager read) freezes contributions before user blocks run — wire with `set(provider)` instead. - Using a plain `MutableList` field on the extension loses laziness, input tracking, and CC-safety. - Forgetting that `add` won't reliably extend a `convention`; if you need defaults to always survive, seed an explicit base or document override semantics.
- Why does this pattern avoid afterEvaluate?Wiring task input to the extension property with set(provider) is lazy: the task resolves the aggregate at fingerprint time, after all configuration blocks have run, so you never need to defer reads to afterEvaluate.
- A second apply of the plugin or a second myTool {} block adds more modules. Are they all included?Yes — each call is a deferred add on the same property, so contributions from any block (or compatible plugin) accumulate into the single resolved collection.
- What breaks if the extension uses a plain MutableList<String> instead?You lose lazy wiring, automatic task dependencies, proper @Input snapshotting, and configuration-cache compatibility; reads also risk freezing before all contributors run.
saying these in an interview costs you the question
- Reaching for afterEvaluate to read the aggregate instead of lazy provider wiring.
- Backing the extension with an eager MutableList.
- Calling get() in apply() and capturing a stale value.