How do you write a plugin that uses a configuration-cache-incompatible API only when the cache is not active, degrading gracefully instead of breaking the build?
answer
- branch on active, fall back when false
- inject services not Project
- Property/Provider for inputs
- warn on requested && !active
- gate is a bridge, then delete
basics
~10 sQuery buildFeatures.configurationCache.active. If false, take the legacy/incompatible path; if true, take the cache-safe path. This lets older plugins keep working with the cache on without crashing.
solid answer
~40 sThe pattern is runtime feature-gating via `BuildFeatures`. Read `buildFeatures.configurationCache.active.getOrElse(false)` and branch: when **active is true**, take only the configuration-cache-compatible code path (no references to `Project` at execution time, no `Task.project`, use injected services and `Provider`s); when **active is false**, you may fall back to the legacy API that would otherwise trigger a cache problem. This is graceful degradation: a plugin that hasn't fully migrated stays functional when the cache is on, and full-fidelity when it's off, rather than failing hard. Crucially, branch on `active`, not `requested`, so the decision matches the effective runtime state. You can also surface a warning when `requested && !active`. The long-term goal is to remove the incompatible branch entirely, but gating buys migration time without forcing every consumer onto the slow path.
code
kotlin · 16 linesabstract class ReportTask @Inject constructor(
private val buildFeatures: BuildFeatures
) : DefaultTask() {
@get:Input abstract val moduleName: Property<String>
@TaskAction
fun action() {
if (buildFeatures.configurationCache.active.getOrElse(false)) {
// cache-safe: use captured Property, no project access
logger.lifecycle("report for ${moduleName.get()} (cc on)")
} else {
// legacy fallback path permitted
logger.lifecycle("report for ${project.name} (cc off)")
}
}
}go deeper
Recall that you can check active and skip the incompatible code when the cache is on.
Show the active-gated branch and name what makes the safe branch cache-compatible (injected services, Providers).
Design the full bridge: branch on active, inject services, emit a degradation warning, plan removal of the legacy branch.
Set org policy: gating is temporary tech debt with a removal deadline; track unmigrated gates as a backlog item and prevent them becoming permanent.
## The migration tension The configuration cache forbids certain things at execution time: holding a reference to `Project`, calling `Task.getProject()`, reading mutable build state late, etc. A plugin that relies on such APIs would make every cache run fail. But you may not be able to migrate everything at once, or you depend on an API that has no cache-safe equivalent yet. `BuildFeatures` lets you *gate* the incompatible behaviour so it only runs when the cache is off. ## The pattern ```kotlin import org.gradle.api.configuration.BuildFeatures import javax.inject.Inject abstract class MyTask @Inject constructor( private val buildFeatures: BuildFeatures ) : DefaultTask() { @TaskAction fun run() { val ccActive = buildFeatures.configurationCache.active.getOrElse(false) if (ccActive) { cacheSafePath() // only injected services / Providers, no Project at exec time } else { legacyPath() // may touch APIs incompatible with the cache } } } ``` ## Rules for the cache-safe branch - Inject services (`BuildFeatures`, `ExecOperations`, `FileSystemOperations`, `ObjectFactory`) rather than capturing `Project`. - Capture inputs eagerly into `Property`/`Provider` during configuration; read them at execution. - Never call `task.project` inside `@TaskAction`. ## Why gate on `active` If you gated on `requested`, a build that *requested* the cache but had it overridden off would needlessly run the cache-safe (possibly more limited) branch, and — worse — a build where `active` is false but you assumed it true could break. `active` is the single source of truth for "is the cache operating right now?". ## Graceful degradation, not silence Degradation should be observable. A common companion is: ```kotlin if (buildFeatures.configurationCache.requested.getOrElse(false) && !buildFeatures.configurationCache.active.getOrElse(false)) { logger.warn("Configuration cache requested but inactive; running legacy path.") } ``` This tells users their opt-in isn't taking effect so they can investigate rather than silently losing the speedup. ## End state Feature-gating is a *bridge*, not a destination. Once the cache-safe path is proven, you delete the legacy branch and the `BuildFeatures` check, leaving clean cache-compatible code. Keeping the gate forever hides unmigrated debt.
- Why not just always take the cache-safe path and delete the legacy branch?Eventually yes — that is the goal. The gate exists only during migration when the safe path isn't ready or an incompatible API has no equivalent yet.
- What should you log to make degradation observable?Warn when requested is true but active is false, so users know their config-cache opt-in isn't taking effect and can investigate.
saying these in an interview costs you the question
- Gating the incompatible path on requested instead of active.
- Treating feature-gating as a permanent solution rather than a migration bridge.
- Silently degrading with no log, hiding lost cache benefits from users.