skip to content

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?

level: principalimportance: nice to knowfreq 18%

answer

  1. static fields leak across daemon builds
  2. move state into service, atomics/concurrent maps
  3. inputs → managed Params
  4. declare via @ServiceReference
  5. AutoCloseable flush; verify with --configuration-cache --parallel

basics

~10 s

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

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

for a junior

Knows static shared state is bad and a BuildService should replace it.

for a middle

Can move state into a service, register it, and wire consumers with @ServiceReference/usesService.

for a senior

Explains daemon leakage, config-cache reconstruction from params, and thread-safety, plus how to verify with flags.

for a principal

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.

context