skip to content

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.

level: principalimportance: should knowfreq 28%

answer

  1. diagnose via problems report
  2. captured Project → lazy Params
  3. static field → thread-safe service field
  4. registerIfAbsent once, @ServiceReference on tasks
  5. final aggregate in close(); verify reuse

basics

~20 s

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

Identify 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 lines
kotlin
abstract 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

for a junior

Recognize statics + captured Project as the cause and that a BuildService is the fix.

for a middle

Describe moving state into the service and config into Params, registering once, and referencing via @ServiceReference.

for a senior

Add verification via the problems report/reuse and thread-safety of the migrated state, plus close() for aggregates.

for a principal

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().

context