Why is OperationCompletionListener (via BuildEventsListenerRegistry) preferred over gradle.addListener for observing task execution in modern Gradle?
answer
- OperationCompletionListener.onFinish(FinishEvent)
- TaskFinishEvent / TaskOperationResult (fromCache, upToDate)
- BuildEventsListenerRegistry.onTaskCompletion(provider)
- wired via shared BuildService
- configuration-cache compatible
basics
~10 sOperationCompletionListener is registered through the injected BuildEventsListenerRegistry service and receives FinishEvents (including TaskFinishEvent) without holding mutable global state, so it stays compatible with the configuration cache — unlike gradle.addListener.
solid answer
~40 sLegacy hooks like `gradle.addListener(TaskExecutionListener)` and `taskGraph.afterTask` carry live, mutable, build-scoped state and are flagged **incompatible with the configuration cache**, because the cache serializes the configured model and replays it without re-running configuration callbacks. The Tooling-API path solves this: you implement `org.gradle.tooling.events.OperationCompletionListener`, whose `onFinish(FinishEvent)` receives typed events — notably `TaskFinishEvent` with a `TaskOperationResult` (success/failure/skipped/from-cache). You register it by injecting `BuildEventsListenerRegistry` (a Gradle service) into your plugin or a `BuildService`, then calling `registry.onTaskCompletion(provider)`, where the provider yields a `BuildService` implementing the listener. Because the listener lives in a shared `BuildService` and is wired through a `Provider`, Gradle can manage its lifecycle and keep it cache-safe. This is the supported way to build profiling, metrics, or build-scan-style observers today.
code
kotlin · 8 linesabstract class Listener : BuildService<BuildServiceParameters.None>, OperationCompletionListener {
override fun onFinish(event: FinishEvent) {
if (event is TaskFinishEvent) println(event.descriptor.taskPath)
}
}
@get:Inject abstract val registry: BuildEventsListenerRegistry
val svc = gradle.sharedServices.registerIfAbsent("l", Listener::class.java) {}
registry.onTaskCompletion(svc)go deeper
Likely beyond junior scope; at most know that a newer, cache-friendly listener API exists.
Name OperationCompletionListener and BuildEventsListenerRegistry and that they replace gradle.addListener for cache compatibility.
Explain the full wiring through a BuildService, the typed TaskFinishEvent/result, and why the indirection is what makes it cache-safe.
Set org policy: profiling/metrics listeners ship as cache-safe BuildServices in convention plugins; ban gradle.addListener in shared build logic to protect configuration-cache adoption.
## The compatibility problem The **configuration cache** lets Gradle skip the configuration phase on subsequent builds by serializing the configured task graph and reloading it. Anything registered as a *live build-scoped callback* — `gradle.addListener(...)`, `taskGraph.afterTask { }`, `buildFinished { }` — can't be serialized meaningfully and is reported as a configuration-cache problem. So the old `TaskExecutionListener` approach blocks one of Gradle's biggest performance features. ## The cache-safe replacement Gradle's Tooling-API events provide a managed, cache-friendly observation channel: - `OperationCompletionListener` (interface) with `onFinish(FinishEvent event)`. - Events arrive typed: `TaskFinishEvent` for tasks, carrying a `TaskOperationResult` that is a `TaskSuccessResult`, `TaskFailureResult`, `TaskSkippedResult`, etc. From a success result you can read `isFromCache()` and `isUpToDate()`. - Registration goes through the injected **`BuildEventsListenerRegistry`** service: `registry.onTaskCompletion(listenerProvider)`. ## Wiring it through a BuildService The listener is supplied as a `Provider<out OperationCompletionListener>`, almost always a **shared `BuildService`** (`gradle.sharedServices.registerIfAbsent(...)`). The service holds any aggregated state (timings, counts) in a thread-safe way and survives across the build; Gradle manages its lifecycle and disposes it at build end. This indirection is exactly what makes it cache-safe: no global mutable listener is captured during configuration. ```kotlin abstract class TimingListener : BuildService<BuildServiceParameters.None>, OperationCompletionListener, AutoCloseable { override fun onFinish(event: FinishEvent) { if (event is TaskFinishEvent) { val ms = (event.result.endTime - event.result.startTime) println("${event.descriptor.taskPath} -> ${ms}ms") } } override fun close() { /* flush metrics */ } } abstract class TimingPlugin : Plugin<Project> { @get:Inject abstract val registry: BuildEventsListenerRegistry override fun apply(project: Project) { val svc = project.gradle.sharedServices.registerIfAbsent( "timing", TimingListener::class.java) {} registry.onTaskCompletion(svc) } } ``` ## What the events give you From `TaskFinishEvent` you get the task path, start/end time, and a result you can pattern-match: was it a failure, skipped, up-to-date, or served from the build cache. That's enough to build per-task timing, failure auditing, or cache-hit-rate reporting — the same things people used `afterExecute` for — but compatible with the configuration cache and the parallel/worker execution model. ## When the legacy API is still acceptable If a build deliberately doesn't use the configuration cache, `taskGraph.afterTask`/`TaskExecutionListener` still works and is simpler. But for any new shared plugin or a build that wants cache benefits, `OperationCompletionListener` is the correct choice. ## Summary `gradle.addListener` = simple, global, mutable, **cache-incompatible**. `OperationCompletionListener` + `BuildEventsListenerRegistry` + `BuildService` = managed, typed, thread-safe, **cache-compatible** — the modern standard.
- Why must the listener be supplied as a Provider/BuildService rather than a plain object?So Gradle controls its instantiation and lifecycle and can keep it out of the serialized configuration model — that indirection is what makes the registration configuration-cache compatible and lets the service hold shared state safely under parallel execution.
- How do you tell from a TaskFinishEvent whether the result came from the build cache?Inspect event.result: if it's a TaskSuccessResult, call isFromCache() (and isUpToDate()) to distinguish a cache hit, an up-to-date no-op, and a real execution.
- Does onFinish fire for non-task operations?Yes — OperationCompletionListener can receive other FinishEvent subtypes; you typically guard with `if (event is TaskFinishEvent)` to handle only tasks.
saying these in an interview costs you the question
- Saying gradle.addListener is configuration-cache compatible — it is not.
- Registering the listener as a captured local object instead of a BuildService/Provider, defeating the cache-safety.
- Reading TaskState from these Tooling-API events — they expose TaskOperationResult, not TaskState.