A legacy plugin keeps shared state (a counter and a cache) in static fields, breaking the configuration cache and parallel builds. How would you migrate it to a BuildService?
answer
- static fields leak across daemon builds
- move state into service, atomics/concurrent maps
- inputs → managed Params
- declare via @ServiceReference
- AutoCloseable flush; verify with --configuration-cache --parallel
basics
~10 sMove the static state into a BuildService implementation, register it with registerIfAbsent, make tasks declare it via @ServiceReference/usesService, and use thread-safe structures so it works under the configuration cache and parallel execution.
solid answer
~40 sReplace the static singleton with a `BuildService<Params>`: move the mutable state (counter → `AtomicInteger`, cache → `ConcurrentHashMap`) inside the service, push any configuration into a managed `Params` interface, and construct live resources from those params in the service body. Register once with `gradle.sharedServices.registerIfAbsent("name", Type)`, returning a `Provider`. Convert every consumer to declare the service — `@ServiceReference("name")` on tasks is cleanest, or `usesService(provider)` for imperative wiring — so Gradle tracks lifecycle, enforces any `maxParallelUsages`, and recognizes the reference under the configuration cache. Implement `AutoCloseable` to flush/close the cache at build end. The payoff: no static state for the config cache to choke on, correct behavior across parallel tasks, deterministic cleanup, and a single shared instance instead of classloader-scoped globals that misbehave across builds in the daemon.
code
kotlin · 7 lines// Before (broken): object Globals { val counter = AtomicLong(); val cache = HashMap<String,String>() }
// After: a registered, declared BuildService
val stats = gradle.sharedServices.registerIfAbsent("stats", StatsService::class) {
parameters.cacheDir.set(layout.buildDirectory.dir("stats"))
}
tasks.withType<MyTask>().configureEach { /* @ServiceReference wires it */ }go deeper
Knows static shared state is bad and a BuildService should replace it.
Can move state into a service, register it, and wire consumers with @ServiceReference/usesService.
Explains daemon leakage, config-cache reconstruction from params, and thread-safety, plus how to verify with flags.
Drives the migration as policy: bans static plugin state in review, standardizes service naming and Params conventions, and defines verification gates across the org's builds.
## Why static state is the problem In a long-lived Gradle **daemon**, static fields persist across builds and are scoped to the plugin classloader — leaking state between invocations. The **configuration cache** forbids capturing such shared mutable state (and `Project`) into the serialized task graph, and **parallel execution** means concurrent mutation without synchronization. A BuildService fixes all three. ## Step-by-step migration ### 1. Identify the state and its inputs A counter (`static AtomicLong`) and a cache (`static Map`). Inputs: maybe a base directory or capacity. ### 2. Define the service + managed Params ```kotlin abstract class StatsService : BuildService<StatsService.Params>, AutoCloseable { interface Params : BuildServiceParameters { val cacheDir: DirectoryProperty } private val counter = AtomicLong() private val cache = ConcurrentHashMap<String, String>() private val dir = parameters.cacheDir.get().asFile.apply { mkdirs() } fun next() = counter.incrementAndGet() fun cache(key: String, value: String) { cache[key] = value } override fun close() { /* persist cache to dir, clear maps */ } } ``` State is now **instance** state with thread-safe types; inputs flow through `Params` (serializable, config-cache friendly). ### 3. Register once in the plugin ```kotlin class MyPlugin : Plugin<Project> { override fun apply(project: Project) { val stats = project.gradle.sharedServices.registerIfAbsent("stats", StatsService::class) { parameters.cacheDir.set(project.layout.buildDirectory.dir("stats")) // maxParallelUsages.set(...) only if the resource is constrained } project.tasks.withType(MyTask::class).configureEach { // wiring handled by @ServiceReference, or: usesService(stats) } } } ``` ### 4. Make consumers declare the service ```kotlin abstract class MyTask : DefaultTask() { @get:ServiceReference("stats") abstract val stats: Property<StatsService> @TaskAction fun run() { val id = stats.get().next(); /* ... */ } } ``` Declaration (here via `@ServiceReference`) is what lets Gradle manage lifecycle, enforce concurrency limits, and treat the reference correctly under the config cache. Avoid leaving any code path that reads the old static field. ### 5. Cleanup and verification - `AutoCloseable.close()` flushes the cache deterministically at build end. - Run with `--configuration-cache` and `--parallel` to confirm no problems are reported and results are stable across a cache hit. ## Design guidance for an org - One service per distinct shared resource; name them consistently to avoid collisions (registration is idempotent by name). - Keep `Params` declarative and serializable; build live resources inside the service. - Default to thread-safe internals even when `maxParallelUsages` is set. - Treat any surviving `static` mutable field in a plugin as a review-blocking smell.
- Why is static state especially dangerous in the Gradle daemon, beyond the configuration cache?The daemon is a long-lived JVM reused across builds; static fields keep their values between invocations, leaking state from one build into the next and producing nondeterministic results.
- How do you verify the migration actually fixed the config-cache problem?Run with --configuration-cache (and --parallel): a clean run plus a cache-hit run with no reported problems and stable, correct output confirms the shared state is now handled by the service.
- Do you always need maxParallelUsages after migrating?No — only if the resource is genuinely constrained. The migration's core value is config-cache safety and thread-safe shared state; the cap is an optional, separate concern.
saying these in an interview costs you the question
- Leaving any code path that still reads the old static field after introducing the service.
- Putting non-serializable live objects into Params instead of rebuilding them in the service.
- Assuming the migration alone makes the code thread-safe without using atomic/concurrent structures.