You inherit a plugin that uses a static field and a captured Project reference to accumulate build-wide data, and it fails with configuration cache enabled. Outline the migration to a BuildService.
answer
- diagnose via problems report
- captured Project → lazy Params
- static field → thread-safe service field
- registerIfAbsent once, @ServiceReference on tasks
- final aggregate in close(); verify reuse
basics
~20 sMove the static state into an abstract BuildService<Params>, pass any needed config (paths, flags) via Params instead of capturing Project, register it with registerIfAbsent, and have each task reference it via @ServiceReference. Remove statics and execution-time Project access.
solid answer
~40 sIdentify what the static actually holds (a counter, a map, an output sink) and what live objects it captures. Define an abstract `MyService : BuildService<MyParams>` where `MyParams : BuildServiceParameters` carries the previously-captured configuration as lazy properties (`DirectoryProperty`, `Property<Boolean>`, etc.) — this replaces the captured `Project`. Make the internal state thread-safe. Register it once with `gradle.sharedServices.registerIfAbsent("my", MyService::class) { parameters { ... } }`. Convert tasks to hold `@get:ServiceReference("my") abstract val svc: Property<MyService>` and move all state access into the `@TaskAction`, deleting any execution-time `project.` access. If the service writes a final aggregate, do it in `close()`. The result: no statics, no live Project at execution time, one shared instance — fully config-cache compatible. Verify with `--configuration-cache` and the problems report.
code
kotlin · 16 linesabstract class CollectorService :
BuildService<CollectorService.Params>, AutoCloseable {
interface Params : BuildServiceParameters {
val outputDir: DirectoryProperty
}
private val data = java.util.concurrent.ConcurrentHashMap<String, Int>()
fun add(k: String) { data.merge(k, 1, Int::plus) }
override fun close() {
parameters.outputDir.get().file("report.txt").asFile
.writeText(data.entries.joinToString("\n"))
}
}
val collector = gradle.sharedServices.registerIfAbsent(
"collector", CollectorService::class
) { parameters { outputDir.set(layout.buildDirectory.dir("collector")) } }go deeper
Recognize statics + captured Project as the cause and that a BuildService is the fix.
Describe moving state into the service and config into Params, registering once, and referencing via @ServiceReference.
Add verification via the problems report/reuse and thread-safety of the migrated state, plus close() for aggregates.
Define org-wide policy banning statics/Project-at-execution, provide a reusable service template, and own the migration rollout.
## Step 0 — diagnose what breaks Run with `--configuration-cache` and read the **problems report** (HTML). The classic offenders here are: - a `companion object` / static field holding accumulated state, and - a captured `Project` (or `Task`/`Gradle`) reference dereferenced in a task action. The config cache forbids live `Project` access at execution time and can't preserve/share statics across its serialized graph. ## Step 1 — model the state as a service ```kotlin interface CollectorParams : BuildServiceParameters { val outputDir: DirectoryProperty // was: project.layout.buildDirectory val verbose: Property<Boolean> // was: project.hasProperty(...) } abstract class CollectorService : BuildService<CollectorParams>, AutoCloseable { private val data = java.util.concurrent.ConcurrentHashMap<String, Int>() fun add(key: String) { data.merge(key, 1, Int::plus) } override fun close() { val out = parameters.outputDir.get().file("report.txt").asFile out.writeText(data.entries.joinToString("\n")) } } ``` Note how every formerly-captured `Project` lookup becomes a **lazy Param** resolved at configuration time and stored as serializable property state — not a live reference. ## Step 2 — register once ```kotlin val collector = gradle.sharedServices.registerIfAbsent("collector", CollectorService::class) { parameters { outputDir.set(layout.buildDirectory.dir("collector")) verbose.set(providers.gradleProperty("verbose").map { it.toBoolean() }.orElse(false)) } } ``` `registerIfAbsent` makes this safe even if the plugin is applied to multiple projects — they share one collector. ## Step 3 — rewire tasks ```kotlin abstract class CollectTask : DefaultTask() { @get:ServiceReference("collector") abstract val collector: Property<CollectorService> @get:Input abstract val key: Property<String> @TaskAction fun run() { collector.get().add(key.get()) } } ``` All state mutation now goes through the injected service inside the `@TaskAction`. There is **no static** and **no `project.` at execution time**. ## Step 4 — verify - Re-run with `--configuration-cache`; the problems report should be clean. - Run twice to confirm cache **reuse** ("Reusing configuration cache"). - If parallel, confirm thread safety holds (you used `ConcurrentHashMap`). ## Governance angle At org scale, codify this: shared build-wide accumulation **must** use a BuildService; statics and execution-time `Project` access are banned. Provide a template service so teams converge instead of each reinventing (and re-breaking) the pattern. This is the canonical migration target for config-cache-incompatible shared state.
- How do you replace a captured Project reference that the old code used at execution time?Resolve the needed values at configuration time and pass them through the BuildServiceParameters as lazy properties (DirectoryProperty, Property<T>). The service reads parameters.* instead of touching Project, which is forbidden at execution time.
- How do you confirm the migration actually fixed the config cache?Run with --configuration-cache and check the problems report is clean, then run again and confirm 'Reusing configuration cache'. Also exercise parallel execution to validate thread safety.
- Where should the final accumulated report be written now?In the service's close() (AutoCloseable), which Gradle calls once after the last using task, instead of in a static shutdown hook or a task that assumed a shared static.
saying these in an interview costs you the question
- Keeping the static field 'just in case' alongside the service — reintroduces the incompatibility.
- Passing the Project into Params to keep old code working — Project is not a valid serializable parameter.
- Writing the aggregate from a single task action, which is fragile if task ordering changes; use close().