What this post covers
This is the eighth post in the "Digging into Gradle's Source" series. In part 7 we followed a plugin from the plugins {} block all the way to apply(), and at the end we saw plugins like JavaPlugin register a whole batch of tasks with tasks.register(…). This post follows how those registered tasks come into being, get configured, and get wired to each other.
Going back to the map in part 2, this post zooms into the third box of the "D · configuration phase" area on the big map: "task registration · Provider (lazy configuration)". Part 6 ran the build script and part 7 applied plugins; most of what those two do ends up as registering something in the task container and wiring values into Property objects. In part 5 we saw that the configuration block of a registered task printed after projectsEvaluated. Why that happens is this post's first topic.
We'll answer four questions:
- What does
tasks.register("x") { … }return, and when does the configuration block run? How is it different fromcreate? - How does
configureEachreach tasks that haven't been created yet? Which APIs break the laziness? - Who creates the task object, and where do
doFirst,doLastand@TaskActionpile up? - Why does wiring an output
Providerinto an input give you ordering withoutdependsOn? From when on can aPropertyno longer be changed?
| Item | Details |
|---|---|
| Target | Gradle Build Tool |
| Version | 9.10.0 development build (master) |
| Commit | 07ecbf9ead (2026-10-24) |
| Main classes | DefaultTaskContainer (+TaskCreatingProvider), DefaultNamedDomainObjectCollection, DefaultDomainObjectCollection, TaskFactory, AnnotationProcessingTaskFactory, AbstractTask/DefaultTask, AbstractMinimalProvider, TransformBackedProvider, AbstractProperty/DefaultProperty, ValueSupplier.ValueProducer, ValueState, DefaultTaskInputs |
All line numbers in this post refer to this commit. How to check out the same commit and build the Docker image used for the examples is in part 1. I also use this blog's own build.gradle.kts as an example a couple of times; that build uses Gradle 9.5.1 through its wrapper, so it is a different version from the source we read here.
Domain object collections and the Provider API are among the largest chunks under platforms/core-configuration (ValueSupplier.java alone is over 900 lines). This post sticks to the main path. What I left out is listed at the end under "What this post leaves out", with names and locations only.
Terms to know first
Task container: the list of tasks that every project has exactly one of. The tasks in a build script is this object, and its implementation is DefaultTaskContainer. It is created as a project-scoped service (ProjectScopeServices; the service registry from part 4).
Domain object collection: the collection type Gradle uses for all the "named things users pile up". The task container, configurations, sourceSets and repositories all belong to this family, which runs DefaultDomainObjectCollection → DefaultNamedDomainObjectCollection → … → DefaultTaskContainer. So what we see about configureEach, named and withType applies not only to tasks but to the whole family.
Register and realize are the two key words of this post. Registering only hands over a promise: "there will be a task with this name and type, and when you create it, configure it with this block". Realizing takes that promise out and actually creates the task object and runs the stored blocks. The build operation names in the source (a build operation is a named record of "what Gradle did, from when to when"; see part 5, section 8) are literally Register task :x and Realize task :x.
Lazy configuration is the umbrella term for this "don't create or compute anything until it's needed" approach. The user manual calls the task side "configuration avoidance" (the page the TaskContainer Javadoc links to is named task_configuration_avoidance).
Provider and Property are objects that hold "a way to get a value" instead of the value itself. A Provider<T> is read-only; a Property<T> can be given a value (or another Provider). The value is computed only when you call get().
Producer: the task that produces a value. In the source it is a small object, ValueSupplier.ValueProducer, and every Provider can report "my value is made by this task". It is the star of section 6.
1. The big picture: register does not create a task
Let's start with the most common line in any build script. In the Kotlin DSL, tasks.register("lazyA") { … } is TaskContainer.register(String, Action), and it ends up in this method of DefaultTaskContainer:
private <T extends Task> TaskProvider<T> registerTask(final String name, final Class<T> type, @Nullable final Action<? super T> configurationAction, final Object... constructorArgs) {
if (hasWithName(name)) {
failOnDuplicateTask(name);
}
final TaskIdentity<T> identity = taskIdentityFactory.create(name, type, project, getUserCodeSource());
TaskProvider<T> provider = buildOperationRunner.call(new CallableBuildOperation<TaskProvider<T>>() {
// ... description(): "Register task :<name>" ...
@Override
public TaskProvider<T> call(BuildOperationContext context) {
TaskProvider<T> provider = Cast.uncheckedNonnullCast(
getInstantiator().newInstance(
TaskCreatingProvider.class, DefaultTaskContainer.this, identity, configurationAction, constructorArgs
)
);
addLaterInternal(provider);
context.setResult(REGISTER_RESULT);
return provider;
}
});
// ...
return provider;
}
subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java lines 440–471
The method does exactly four things:
- If the name already exists, it fails (
hasWithName()checks both created and pending ones). - It builds a
TaskIdentitythat bundles name, type and project. - It creates one
TaskCreatingProvider. What goes into its constructor is the identity and the configuration block (configurationAction). Not a task object. - It puts that provider on the collection's pending list with
addLaterInternal()and returns it.
No Task object is created anywhere. The configuration block isn't called either; it is just stored inside the provider. All of this is wrapped in a build operation called Register task :lazyA. The elided lines 466–468 call get() right away if eagerlyCreateLazyTasks is set; that flag is an internal switch, turned on by the system property org.gradle.internal.tasks.eager (line 79), that makes registration create tasks immediately.
The top half (①–④) happens while the build script runs in the configuration phase; the bottom half (⑤–⑨) happens when someone first needs that task.
Example 1: when does the configuration block run?
I wrote a build script that prints a line from every block.
// This example was written for this post (it is not Gradle's source)
// Print from every block to see *when* a task's configuration block runs.
println("[script] start")
// Lazy configuration that applies to tasks that exist now and tasks created later
tasks.configureEach {
println("[configureEach] $name")
}
// register: only hand over a name and a configuration block
val lazyA = tasks.register("lazyA") {
println("[register block] lazyA")
// ... with -Pstack, print the call stack (section 2) ...
doLast { println("[action] lazyA") }
}
println("[script] register returned ${lazyA.javaClass.simpleName}")
tasks.register("lazyB") {
println("[register block] lazyB")
}
// Adding configuration with named still does not create the task
tasks.named("lazyB") {
println("[named block] lazyB")
}
// create: creates and configures on the spot (@Deprecated at this commit)
@Suppress("DEPRECATION")
tasks.create("eager") {
println("[create block] eager")
}
// ... with -PeagerAll, add withType<DefaultTask> { } (section 3) ...
println("[script] end")
[Shortened from the example ex/u08-register-vs-create/project/build.gradle.kts]
This example was run with the 9.10.0 development build of Gradle, built straight from the base commit (07ecbf9ead), in a Docker container (Eclipse Temurin JDK 25). run.sh runs gradle help --offline --console=plain and keeps only lines starting with [ (the other outputs in this post are also filtered by run.sh with grep).
=== gradle help ===
[script] start
[script] register returned DefaultTaskContainer$TaskCreatingProvider_Decorated
[configureEach] eager
[create block] eager
[script] end
[configureEach] help
BUILD SUCCESSFUL in 20s
There are four things to read here. (Timings differ on every run, in this output and in every other output in this post. Every container starts a fresh daemon and compiles the script from scratch, so the first run took anywhere from 20 seconds to about a minute and a half across my runs.)
- What
registerreturned is aTaskCreatingProvider_Decorated— the provider from step 3 above. The_Decoratedsuffix is the subclass Gradle generates at runtime, which we saw in part 4 (it was created throughgetInstantiator().newInstance(…)). - The blocks of
lazyAandlazyBprinted nothing at all. Neither did thenamedblock. This build only runshelp, so those two tasks were never needed. - For
eager, created withcreate, both theconfigureEachblock and thecreateblock ran right there, while the script was running. - The
configureEachline forhelpwas printed after[script] end. Thehelptask is also registered by Gradle withregister, so it was first created when the tasks to run were being selected.
Running the same build again with the build operation trace (-Dorg.gradle.internal.operations.trace=/tmp/trace, see part 1) and keeping only Register task and Realize task gives this:
=== build operations of gradle help: Register / Realize ===
"displayName":"Register task :help"
"displayName":"Register task :projects"
"displayName":"Register task :tasks"
...
"displayName":"Register task :kotlinDslAccessorsReport"
"displayName":"Register task :lazyA"
"displayName":"Register task :lazyB"
"displayName":"Realize task :eager"
"displayName":"Realize task :help"
(There are eighteen Register task lines; I cut the twelve in the middle.) Even though this build applies no plugins, Gradle registers sixteen tasks on its own (help, tasks, wrapper, …). Add my two, and we get 18 registered, 2 realized. eager was made with create, so it goes straight to Realize without a Register; the other seventeen stayed registered-only until the build ended.
How is create different?
The body of create (doCreate()) does this inside a Realize task build operation:
T task = createTask(identity, constructorArgs);
statistics.eagerTask(identity.getTaskType());
addTask(task, behavior);
configureAction.execute(task);
subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java lines 336–339
It creates the task on the spot (createTask), adds it to the collection (addTask — this is when container-level actions such as configureEach run), and then calls the configuration block. That order is why [configureEach] eager printed before [create block] eager in the example.
Easy to get wrong
createis@Deprecatedat this commit. All eightcreate(…)overloads onTaskContainercarry it, and the Javadoc says to useregisterinstead (TaskContainer.javalines 168–172, among others). The released 9.8.2TaskContaineralready has the same marks. However,DefaultTaskContainer'screatedoes not call any code that prints a warning at runtime (DeprecationLogger). It is only an annotation the compiler sees, which is why the example adds@Suppress("DEPRECATION")to silence the Kotlin compiler warning.TaskContainereffectively has noadd.add(Task)andaddLater(Provider)all throwUnsupportedOperationException("… Use register() instead." or "… Use the register() method instead.") (lines 791–811). A task must always be created by the container, which attaches its identity (section 4).
2. When does realization happen?
A stored provider turns into a real task when someone computes the provider's value. That code lives in TaskCreatingProvider's parent, AbstractDomainObjectCreatingProvider (shared by all domain object collections).
@Override
protected Value<? extends I> calculateOwnValue(ValueConsumer consumer) {
// ...
if (failure != null) {
throw failure;
}
if (object == null) {
object = getType().cast(findByNameWithoutRules(getName()));
if (object == null) {
tryCreate();
}
}
return Value.of(object);
}
protected void tryCreate() {
try {
// Collect any container level add actions added since the last call to configure()
onCreate = onCreate.mergeFrom(getEventRegister().getAddActions());
// ...
object = createDomainObject();
// ...
doAdd(object, onCreate);
// ...
} catch (Throwable ex) {
failure = domainObjectCreationException(ex);
throw failure;
} finally {
// Discard state that is no longer required
onCreate = ImmutableActionSet.empty();
}
}
- The first time the value is asked for,
tryCreate()creates the object (createDomainObject()— for the task container this calls the task factory from section 4). doAdd(object, onCreate)adds the object to the collection and runs the actions piled up inonCreate. Theregisterconfiguration block, blocks added withnamed, andconfigureEachblocks all live in thisonCreate(section 3).- After creation the object is kept in the
objectfield and returned from then on. A failure is also remembered infailure, and asking again throws the same exception. - The
finallyblock clearsonCreate. Once the object exists, the stored blocks are no longer needed.
TaskCreatingProvider wraps this tryCreate() once more in a Realize task :x build operation (lines 737–756).
Who asks for the value? Example 1 with gradle lazyA
I ran the same example as gradle lazyA -Pstack. With -Pstack, lazyA's configuration block prints the Gradle frames that called it.
if (providers.gradleProperty("stack").isPresent) {
// Who calls this block? Print only Gradle's frames
Throwable().stackTrace
.filter { it.className.startsWith("org.gradle.") }
.forEach { println(" at ${it.className}.${it.methodName}") }
}
[From the example ex/u08-register-vs-create/project/build.gradle.kts]
=== gradle lazyA -Pstack ===
...
[script] end
[configureEach] lazyA
[register block] lazyA
...
at org.gradle.api.internal.DefaultDomainObjectCollection.doAdd
at org.gradle.api.internal.DefaultNamedDomainObjectCollection.doAdd
at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.tryCreate
...
at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.calculateOwnValue
at org.gradle.api.internal.provider.AbstractMinimalProvider.getOrNull
at org.gradle.api.internal.DefaultNamedDomainObjectCollection.findByName
...
at org.gradle.api.internal.DefaultNamedDomainObjectCollection.getByName
at org.gradle.api.internal.tasks.DefaultTaskCollection.getByName
at org.gradle.execution.TaskNameResolver.getExistingTask
...
at org.gradle.execution.DefaultTaskSelector.getSelection
...
at org.gradle.execution.commandline.CommandLineTaskParser.parseTasks
...
at org.gradle.initialization.DefaultTaskExecutionPreparer.scheduleRequestedTasks
...
at org.gradle.internal.build.DefaultBuildLifecycleController$DefaultWorkGraphBuilder.addRequestedTasks
...
> Task :lazyA
[action] lazyA
BUILD SUCCESSFUL in 1s
There were 191 org.gradle. frames; above I kept only the lines relevant to this post and cut the rest as ... (the first five lines are the same as in the gradle help run, so I dropped them too). The stack reads innermost-first. Reading from the bottom up:
- In the lifecycle step from part 5 that "adds the requested tasks to the work graph" (
addRequestedTasks→scheduleRequestedTasks), CommandLineTaskParser→DefaultTaskSelector→TaskNameResolver, which resolve the namelazyAfrom the command line,- called
tasks.getByName("lazyA");findByName()found the provider on the pending list and asked for its value withgetOrNull(), - so
calculateOwnValue()→tryCreate()→doAdd()ran the stored actions, and my block was called.
findByName() looks like this:
@Override
@Nullable
public T findByName(String name) {
T value = findByNameWithoutRules(name);
if (value != null) {
return value;
}
ProviderInternal<? extends T> provider = index.getPending(name);
if (provider != null) {
// TODO - this isn't correct, assumes that a side effect is to add the element
provider.getOrNull();
// Use the index here so we can apply any filters to the realized element
return index.get(name);
}
// ... if not found, try the rules (addRule) ...
If the name isn't among the created objects, it looks at the pending list (index.getPending); if it is there, it calls getOrNull() to trigger the side effect of creation and then looks again. The source comment says as much: it "assumes that a side effect is to add the element". So findByName and getByName are lookup methods and realization methods at the same time.
To save realizations, TaskNameResolver first only checks whether the name is in the name list (getNames().contains(taskName), TaskNameResolver.java lines 110–112). The name list includes the names of pending providers, so it can answer without creating anything. Realization happens only for the selected task, through getByName() (lines 114–120). Following the selected task's dependencies and realizing those is the work graph's job; we'll see it in part 9.
The red boxes create many tasks at once "behind your back". We'll see them in section 3.
Example 1-2: counting in a build with the java plugin
My two tasks don't make the point very vividly, so I counted in a build containing nothing but plugins { java }. Gradle has an internal switch for exactly this. Pass the system property org.gradle.internal.tasks.stats and TaskStatistics prints registration and realization counts at the end of the build (TaskStatistics.java line 43; it prints in lines 116–127). The counting happens in statistics.lazyTask() in the TaskCreatingProvider constructor and in onLazyDomainObjectRealized() on realization.
=== java project: gradle help with task statistics ===
BUILD SUCCESSFUL in 3s
Task counts: Old API 0, New API 31, total 31
Task counts: created 2, avoided 29, %-lazy 94
Task types that were registered with the new API but were created anyways
class org.gradle.api.tasks.Delete 1
class org.gradle.configuration.Help 1
=== java project: gradle tasks --all with task statistics ===
BUILD SUCCESSFUL in 2s
Task counts: Old API 0, New API 31, total 31
Task counts: created 31, avoided 0, %-lazy 0
Task types that were registered with the new API but were created anyways
class org.gradle.api.DefaultTask 8
class org.gradle.language.jvm.tasks.ProcessResources 2
class org.gradle.api.tasks.compile.JavaCompile 2
...
(The second list has twenty-two lines; I kept only the first three. The order of lines with equal counts can change between runs.)
- Including the tasks the
javaplugin registers, all 31 tasks were registered through the "New API" (register). gradle helpcreated only 2 of them (94% avoided).gradle tasks --allhas to list every task, so it creates all 31.
But I only ran help, and one task of type Delete (clean) got created. If you give the same switch a file path (-Dorg.gradle.internal.tasks.stats=/tmp/stats.txt), it writes a stack trace to that file for every realization (TaskStatistics.java lines 107–109), so you can find the culprit. The core of the shortened stack:
...
at org.gradle.api.internal.DefaultNamedDomainObjectCollection$AbstractDomainObjectCreatingProvider.get(DefaultNamedDomainObjectCollection.java:978)
at org.gradle.api.internal.tasks.DefaultTaskContainer$TaskCreatingProvider.getProducer(DefaultTaskContainer.java:734)
at org.gradle.api.internal.provider.TransformBackedProvider.beforeRead(TransformBackedProvider.java:94)
at org.gradle.api.internal.provider.TransformBackedProvider.calculateOwnValue(TransformBackedProvider.java:79)
...
at org.gradle.internal.cleanup.DefaultBuildOutputCleanupRegistry.resolveOutputs(DefaultBuildOutputCleanupRegistry.java:79)
at org.gradle.internal.build.DefaultBuildWorkPreparer.finalizeWorkGraph(DefaultBuildWorkPreparer.java:66)
While finalizing the work graph, Gradle resolves its "build output cleanup list" (DefaultBuildOutputCleanupRegistry); a TransformBackedProvider (a Provider made with map, section 5) calls TaskCreatingProvider.getProducer(), which creates clean with get(). The one who set up that map is Gradle's own LifecycleBasePlugin:
final Provider<Delete> clean = project.getTasks().register(CLEAN_TASK_NAME, Delete.class, cleanTask -> {
cleanTask.setDescription("Deletes the build directory.");
cleanTask.setGroup(BUILD_GROUP);
cleanTask.delete(buildDir);
});
buildOutputCleanupRegistry.registerOutputs(clean.map(cl -> cl.getTargetFiles()));
The root cause is how TaskCreatingProvider reports its producer:
@Override
public ValueProducer getProducer() {
return ValueProducer.taskState(get());
}
subprojects/core/src/main/java/org/gradle/api/internal/tasks/DefaultTaskContainer.java lines 732–735
To answer "who makes this value?", it calls get() on the task. A Provider made with map checks its producer before reading the value (the beforeRead() in section 5), so reading a TaskProvider.map {…} ends up realizing that task. That's why the diagram puts "following dependencies" on the realization side.
Easy to get wrong
nameddoesn't create;getByNameandfindByNamedo. The names are similar, so people mix them up.named(name)finds the provider on the pending list and returns it as is (searchForDomainObject(), lines 847–863), andnamed(name) { }stores the block through that provider'sconfigure()(section 3). That's why[named block] lazyBnever printed in example 1.- A
dependsOnon aTaskProviderstill realizes the task once dependencies are computed. WhenDefaultTaskDependencyreceives aProvider, it asksprovider.getProducer()(DefaultTaskDependency.javalines 113–122), and as we just saw,TaskCreatingProvidercallsget()to answer. What lazy configuration saves you is "tasks that never enter the graph"; tasks that do enter the graph get created anyway.
3. configureEach — what exists now and what comes later
tasks.configureEach { … } attaches a block to "every task that exists now and every task that will ever exist". And it realizes nothing while doing so. Let's see how.
@Override
public void configureEach(Action<? super T> action) {
assertEagerContext("configureEach(Action)");
Action<? super T> wrappedAction = wrapLazyAction(decorate(action));
Action<? super T> registerLazyAddActionDecorated = eventRegister.registerLazyAddAction(wrappedAction);
// ...
Iterator<T> iterator = iteratorNoFlush();
// ... collect the already-created elements into copied ...
if (copied != null) {
for (T next : copied) {
registerLazyAddActionDecorated.execute(next);
}
}
}
There are two branches:
- What comes later:
eventRegister.registerLazyAddAction()puts the block on the collection's list of "things to do when an element is added" (addActions). When section 2'stryCreate()creates a task, it merges this list in withonCreate.mergeFrom(getEventRegister().getAddActions())and runs it, so a pending task receives the block at the moment it is created. - What exists now:
iteratorNoFlush()walks only the elements already created and runs the block on them now. The "NoFlush" in the name is the key: it's an iterator that does not flush (that is, realize) the pending list. The elided comment (lines 206–209) even says that sizing the copy withestimatedSize()would realize pending elements and makeconfigureEacheager, so it isn't used.
Blocks handed over at registration go into the same onCreate. Here are the provider constructor and configure():
public AbstractDomainObjectCreatingProvider(String name, Class<I> type, @Nullable Action<? super I> configureAction) {
super(name, type);
this.onCreate = ImmutableActionSet.<I>empty().mergeFrom(getEventRegister().getAddActions());
if (configureAction != null) {
configure(configureAction);
}
}
// ...
@Override
public void configure(final Action<? super I> action) {
// ...
if (object != null) {
// Already realized, just run the action now
decoratedAction.execute(object);
return;
}
// Collect any container level add actions then add the object specific action
onCreate = onCreate.mergeFrom(getEventRegister().getAddActions()).add(decoratedAction);
}
When the provider is created, it first takes the container actions registered so far (configureEach blocks), then appends the register block. A block added later with named(…) { } is appended after that. If the object has already been created (object != null), the block isn't stored but run immediately. In example 1, configureEach was set up before register, and the output came out as [configureEach] lazyA → [register block] lazyA — this order.
Green boxes postpone realization; red boxes pull it forward. Both end up "attaching a block to every task".
APIs that break laziness: all and withType with a block
all { } looks like it does the same as configureEach, but it works the opposite way.
@Override
public <S extends T> DomainObjectCollection<S> withType(Class<S> type, Action<? super S> configureAction) {
assertEagerContext("withType(Class, Action)");
DomainObjectCollection<S> result = withType(type);
result.all(configureAction);
return result;
}
private Action<? super T> addEagerAction(Action<? super T> action) {
store.realizePending(type);
return eventRegister.registerEagerAddAction(type, decorate(action));
}
all() (lines 168–192) and whenObjectAdded() (lines 245–248) call this addEagerAction(). Its first line, store.realizePending(type), creates every pending element of that type right now. Then registerEagerAddAction() marks that type as "subscribed", and a subscribed type gets created immediately, the moment a later register puts it on the pending list (the isSubscribed check in SortedSetElementSource.addPending(), lines 175–183). Once you use all, even the register calls after it lose their laziness.
The trap here is the Kotlin DSL's tasks.withType<Test> { … }. This extension function, the one that takes a block, calls withType(S::class.java, configuration) — the withType(Class, Action) above (DomainObjectCollectionExtensions.kt lines 34–35). withType with a block is all. To stay lazy, filter with withType() and attach configureEach to the result: tasks.withType<Test>().configureEach { … }.
This blog's own build.gradle.kts has exactly that shape:
tasks.withType<Test> {
useJUnitPlatform()
}
[Lines 34–36 of this blog repository's build.gradle.kts. This is the build that produces the blog, not Gradle's source]
In the portal's Gradle (9.5.1) too, withType(Class, Action) calls all() and addEagerAction() calls realizePending() (in 9.5.1 this file lived under subprojects/core; at the base commit it has moved to platforms/core-configuration/domain-object-collections). This build has only one Test task, test, and running ./gradlew test creates it anyway, so the cost is tiny — but it does mean test gets created even when you run other tasks such as preview.
Example 1-3: adding withType { } with -PeagerAll
Passing -PeagerAll to example 1 turns on this block:
// With -PeagerAll: withType with a block is all(), so it also creates registered-only tasks
if (providers.gradleProperty("eagerAll").isPresent) {
tasks.withType<DefaultTask> {
println("[withType block] $name")
}
}
[From the example ex/u08-register-vs-create/project/build.gradle.kts]
=== gradle help -PeagerAll ===
...
[create block] eager
[configureEach] help
[configureEach] projects
...
[configureEach] lazyA
[register block] lazyA
[configureEach] lazyB
[register block] lazyB
[named block] lazyB
[withType block] artifactTransforms
...
[withType block] lazyA
[withType block] lazyB
...
[withType block] wrapper
[script] end
BUILD SUCCESSFUL in 2s
(I dropped the first three lines, which are the same as in the gradle help run, and cut the middle of the [configureEach] lines and the [withType block] lines, which are eighteen and nineteen lines long.)
Only help is being run, yet all eighteen were created before [script] end, at that line of the script. Even lazyB's named block ran. realizePending() created them in pending order (help … lazyB), then all() walked the collection and ran the block; the task container is a name-sorted set (SortedSetElementSource), so the [withType block] lines came out alphabetically.
Easy to get wrong
- Iteration is realization too.
tasks.forEach { }andfor (t in tasks)ask for an iterator, andSortedSetElementSource.iterator()callsrealizePending()on its first line (lines 82–86).tasks.size, on the other hand, creates nothing.size()(lines 68–75) just adds upcollector.size()for each pending provider, and the collector for a single-task provider (Collectors.ElementFromProvider) returns 1 without asking for the value. In a build that registered two tasks, printingtasks.sizedid not run the registration blocks; they ran only when I then calledtasks.forEach { }(this check was a separate build that isn't shown in the post). withType()without a block is fine. It only returns a filtered view and creates nothing (lines 150–152). The dangerous one is the overload that also takes a block.- You can't change the container inside a lazy block.
configureEachandregisterblocks are wrapped withwrapLazyAction(), and calling a mutating method such asregisteroraddinside them is stopped byassertCanMutate()→assertEagerContext(). The source comment says users cannot mutate the container within a lazy callback (DefaultDomainObjectCollection.javalines 474–476). I did not check the error message for this case with an example.
4. Who creates the task object? The task factory and DefaultTask
Section 2's createDomainObject() goes through DefaultTaskContainer.createTask() to the task factory. There are two task factories stacked on each other. A build-scoped service builds them as new AnnotationProcessingTaskFactory(instantiator, taskClassInfoStore, new TaskFactory()) (BuildScopeServices.java lines 489–496), and each project derives its own with createChild(). The outer AnnotationProcessingTaskFactory calls the inner TaskFactory.
The inner TaskFactory.create() actually creates the object:
final Class<? extends DefaultTask> implType;
if (identity.getTaskType() == Task.class) {
implType = DefaultTask.class;
} else if (DefaultTask.class.isAssignableFrom(identity.getTaskType())) {
implType = identity.getTaskType().asSubclass(DefaultTask.class);
} else if (identity.getTaskType() == org.gradle.api.internal.AbstractTask.class || identity.getTaskType() == TaskInternal.class) {
// ...
} else {
throw new InvalidUserDataException(String.format(
"Cannot create task '%s' of type '%s' as directly extending AbstractTask is not supported.",
// ...
}
// ...
return org.gradle.api.internal.AbstractTask.injectIntoNewInstance(project, identity, new Callable<S>() {
// ...
instance = instantiationScheme.instantiator().newInstanceWithDisplayName(implType, displayName, constructorArgs);
- A
register("x")without a type is registered asDefaultTask.class(register(String), line 431) and simply becomes aDefaultTaskhere. - Every task must be a subclass of
DefaultTask. Types that extendAbstractTaskdirectly are rejected. At this commitAbstractTaskis a@Deprecatedclass "to be removed in Gradle 10" (AbstractTask.javalines 106–111). Yet almost all the state and behavior lives there, and most of whatDefaultTaskdoes is redeclare those methods and call the parent. The source comment explains why: Eclipse treats methods implemented in the deprecatedAbstractTaskas deprecated inDefaultTasktoo (DefaultTask.javalines 51–52). injectIntoNewInstance()is the trick of putting the identity and project in a thread-local before calling the constructor. Why you can't create a task withnew, and why the real object is a…_Decorated(theinstantiatoruses a subclass Gradle generates), were covered in detail, with a stack trace, in section 6 of part 4.
The outer AnnotationProcessingTaskFactory adds what it reads from the class to the freshly created task:
private <S extends Task> S process(S task) {
TaskClassInfo taskClassInfo = taskClassInfoStore.getTaskClassInfo(task.getClass());
for (TaskActionFactory actionFactory : taskClassInfo.getTaskActionFactories()) {
((TaskInternal) task).prependParallelSafeAction(actionFactory.create(instantiator));
}
Methods annotated with @TaskAction enter the action list at this point — right after the task is created and before any configuration block runs. Then lines 57–62 turn on caching for @CacheableTask and turn off state tracking if the class asks for that (part 11).
The only thing I write is Gen; the classes above it and Gen_Decorated below it are all filled in by Gradle.
The action list — doFirst at the front, doLast at the back
public Task doFirst(final String actionName, final Action<? super Task> action) {
// ...
taskMutator.mutate("Task.doFirst(Action)", new Runnable() {
@Override
public void run() {
getTaskActions().add(0, wrap(action, actionName));
}
});
// ...
public Task doLast(final String actionName, final Action<? super Task> action) {
// ...
taskMutator.mutate("Task.doLast(Action)", new Runnable() {
@Override
public void run() {
getTaskActions().add(wrap(action, actionName));
}
});
subprojects/core/src/main/java/org/gradle/api/internal/AbstractTask.java lines 540–570
A task's actions are a single ArrayList (actions, line 119). doFirst inserts at index 0 and doLast appends at the end. @TaskAction is already in the list from creation time, so if the configuration block adds one doFirst and one doLast, the order becomes doFirst → @TaskAction → doLast. If you call doFirst several times, the last one added runs first. The show task in section 6's example adds one doFirst and one doLast in its configuration block and prints actions.size — and gets 3, not 2. The extra one is the @TaskAction that was already there.
Both methods modify the list inside taskMutator.mutate(…). TaskMutator.mutate() is a short method that throws IllegalStateException unless the task state is isConfigurable(), and otherwise runs the given work (TaskMutator.java lines 34–39). isConfigurable() means "not executing yet and not finished executing" (TaskStateInternal). So once a task has started executing, you can no longer change its action list or its input/output declarations (DefaultTaskInputs and DefaultTaskOutputs use the same taskMutator). We'll see the actual message in section 7's example.
5. Provider and Property — "how to get the value" instead of the value
The Provider API applies the same idea as "create tasks late" to values. Let's start with the structure.
The left trunk is "how do we get the value"; ValueProducer on the right is "who makes that value". Section 6 looks at the right side.
map postpones the computation
@Override
public <S> ProviderInternal<S> map(final Transformer<? extends @Nullable S, ? super T> transformer) {
requireNonNull(transformer, "Cannot map a provider using a null transformer.");
// Could do a better job of inferring the type
return new TransformBackedProvider<>(null, this, transformer);
}
map computes nothing; it just returns a TransformBackedProvider that holds "the source Provider and the transform function". The computation happens when someone asks that Provider for its value:
@Override
protected Value<? extends OUT> calculateOwnValue(ValueConsumer consumer) {
// ...
beforeRead(context);
Value<? extends IN> value = provider.calculateValue(consumer);
return mapValue(context, value);
// ...
protected void beforeRead(EvaluationScopeContext context) {
provider.getProducer().visitContentProducerTasks(producer -> {
if (!producer.getState().getExecuted()) {
throw new ProducerNotCompletedException("mapped", provider, producer);
}
});
}
Only when asked does it compute the source value (provider.calculateValue) and transform it. We'll come back to the beforeRead() in front of it in section 6. It was this very beforeRead()'s provider.getProducer() that created clean in section 2.
A Property is a Provider you can "put a value into". What objects.property<String>() creates is a DefaultProperty (DefaultPropertyFactory.property(), lines 50–68), and set(T), which takes a fixed value, wraps it with Providers.fixedValue(…) and passes it to setSupplier() (DefaultProperty.java lines 81–88). The overload that takes a Provider looks like this:
@Override
public void set(Provider<? extends T> provider) {
requireNonNull(provider, "Cannot set the value of a property using a null provider.");
ProviderInternal<? extends T> p = Providers.internal(provider);
setSupplier(p.asSupplier(getValidationDisplayName(), type, sanitizer));
}
Even a fixed value goes in wrapped as a Provider. What a Property holds is always "a supplier that hands out the value" (the value field, whose type parameter is S extends ValueSupplier). Put another Provider in and it holds that Provider as is; the value is computed by following that chain of suppliers at get() time.
Who fills in a task's abstract val …: Property<String>?
The example tasks declare properties only as abstract getters, like @get:Input abstract val who: Property<String>. These getters are implemented by the _Decorated subclass that Gradle generates. In part 4 we saw getters that inject services; getters for properties are generated like this:
public void applyReadOnlyManagedStateToGetter(PropertyMetadata property, Method getter, boolean applyRole) {
// GENERATE public <type> <getter>() {
// if (<field> == null) {
// <field> = getFactory().newInstance(this, <display-name>, <type>, <prop-name>);
// }
// return <field>;
// }
On the first call it creates the property object with ManagedObjectFactory.newInstance(), stores it in a field, and attaches an owner label. When I printed it in example 3, the real class of who was org.gradle.api.internal.provider.DefaultProperty and its toString() was task ':greet' property 'who'. Error messages use this label (section 7). Lines 1221–1224 call applyRole() when needed; that's where section 6 attaches the producer to output properties.
Example 3 (part): map sees the later value
// 3) map postpones the computation — change the source later and the result changes too
val name = objects.property<String>()
val greeting = name.map { "hello, $it" }
name.set("first")
name.set("second")
println("[map] ${greeting.get()}")
[From the example ex/u08-property-finalize/project/build.gradle.kts]
[map] hello, second
name was still empty when map was called, and that was no problem; the result was computed from the last value put in. This is the Provider API's promise that "you don't have to worry about configuration order". A plugin can wire up a value first, the user can change it later in the build script, and whoever reads it later sees the last value.
6. Why ordering appears without dependsOn — ValueProducer
Now for the last big question of this post. I wired a task gen that writes a file to a task show that reads it, like this.
Example 2: wiring an output Provider into an input
// This example was written for this post (it is not Gradle's source)
// Wire a producer task's output Provider into a consumer task's input, and ordering appears without dependsOn.
import org.gradle.api.internal.provider.ProviderInternal
// A task that writes a file
abstract class Gen : DefaultTask() {
@get:Input
abstract val text: Property<String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
// ... @TaskAction: writes text to outputFile and prints "[gen @TaskAction] wrote …" ...
}
// A task that reads that file
abstract class Show : DefaultTask() {
@get:InputFile
abstract val inputFile: RegularFileProperty
// ... @TaskAction: reads inputFile and prints "[show @TaskAction] read: …" ...
}
val gen = tasks.register<Gen>("gen") {
text.set("hello from gen")
outputFile.set(layout.buildDirectory.file("gen/message.txt"))
}
tasks.register<Show>("show") {
// No dependsOn(gen) — we only wire the output Provider into the input
inputFile.set(gen.flatMap { it.outputFile })
doFirst { println("[show doFirst]") }
doLast { println("[show doLast]") }
// Peek with an internal API: who makes this input value?
val producer = (inputFile as ProviderInternal<*>).producer
println("[configure show] producer of inputFile = ${producer.javaClass.simpleName}")
producer.visitProducerTasks { println("[configure show] producer task = $path") }
println("[configure show] actions = ${actions.size}")
}
// ... the -PreadEarly block (below), printing the graph with taskGraph.whenReady ...
[Shortened from the example ex/u08-provider-wiring/project/build.gradle.kts]
ProviderInternal is an internal interface, not public API. I only used it to see with my own eyes what I had read in the source; it's not something to use in a real build script. Here are the results of gradle show --dry-run (-m, which selects tasks without running them) and gradle show:
=== gradle show --dry-run ===
[configure show] producer of inputFile = TaskProducer
[configure show] producer task = :gen
[configure show] actions = 3
[graph] :gen -> :show
:gen SKIPPED
:show SKIPPED
BUILD SUCCESSFUL in 32s
=== gradle show ===
[configure show] producer of inputFile = TaskProducer
[configure show] producer task = :gen
[configure show] actions = 3
[graph] :gen -> :show
> Task :gen
[gen @TaskAction] wrote message.txt
> Task :show
[show doFirst]
[show @TaskAction] read: hello from gen
[show doLast]
BUILD SUCCESSFUL in 3s
I only asked for show and there is no dependsOn, yet :gen entered the graph ahead of :show. Asked who produces its value, show's input inputFile answers TaskProducer, and that task is :gen. The execution order is also as section 4 said: doFirst → @TaskAction → doLast.
Where does the producer get attached?
It starts with gen's @OutputFile property. When the generated getter from section 5 creates the property object, it calls applyRole(), and for a property carrying an output annotation (@OutputFile, @OutputDirectory, …) this handler is called:
@Override
public void applyRoleTo(ModelObject owner, Object target) {
if (target instanceof PropertyInternal) {
((PropertyInternal<?>) target).attachProducer(owner);
}
}
owner is the task that owns the property (gen). From now on, gen.outputFile knows "I am gen's output". AbstractProperty.getProducer() uses that:
@Override
public ValueProducer getProducer() {
Task task = getProducerTask();
if (task != null) {
return ValueProducer.task(task);
} else {
try (EvaluationScopeContext context = openScope()) {
return getSupplier(context).getProducer();
}
}
}
- If it is some task's output (
gen.outputFile), it answersValueProducer.task(gen). - Otherwise (
show.inputFile) it asks the supplier it holds. Whatshow.inputFileholds isgen.flatMap { it.outputFile };FlatMapProvider.getProducer()asks the flattened result (gen.outputFile) (FlatMapProvider.javalines 80–84), which returnsTaskProducer(:gen).mappasses the question to its source in the same way (TransformBackedProviderlines 55–60).
ValueProducer.task() and taskState() look like this:
static ValueProducer task(Task task) {
return new TaskProducer(task, true);
}
// ...
static ValueProducer taskState(Task task) {
return new TaskProducer(task, false);
}
The only difference is one content flag. Per their Javadoc, task() means "the value and its contents are produced by the task" (an output file — the path is known at configuration time, but the contents only exist after the task runs), and taskState() means "the value comes from the task's properties and only carries a dependency on the task" (the TaskProvider itself, from section 2). This flag decides the error in the -PreadEarly example below.
Who asks for dependencies?
What show depends on is asked while building the work graph. A task's dependency list contains not only what you added with dependsOn but also all of its input declarations (taskInputs) (configurableDependency(ImmutableSet.of(taskInputs, lifecycleDependencies)) on line 207 of the AbstractTask constructor). Input-side dependencies are counted by DefaultTaskInputs.visitDependencies() (lines 193–216). PropertyWalker walks the getters of the task class looking for annotations like @Input and @InputFile (the implementation is DefaultPropertyWalker; the per-annotation handler is chosen in DefaultPropertyWalker.java lines 89–101) and calls a visitor for each property. There are two branches:
- Non-file inputs like
@Inputadd the value'sgetTaskDependencies(). If the value is aProvider, the Provider itself plays that role (DefaultPropertyWalkerlines 126–129), andAbstractMinimalProvider.visitDependencies()is, as its comment says, "when used as an input, add the producing tasks if known" — the one linegetProducer().visitDependencies(context)(lines 135–139). - File inputs like the example's
inputFilewrap the value in a file collection (resolveInputFileValue()). Inside it, the Provider becomes aProviderBackedFileCollection, which reports dependencies the same way:
@Override
public void visitDependencies(TaskDependencyResolveContext context) {
ValueSupplier.ValueProducer producer = provider.getProducer();
if (producer.isKnown()) {
producer.visitDependencies(context);
} else {
// Producer is unknown, so unpack the value
// ...
}
}
Either way, it ends at getProducer(). TaskProducer.visitDependencies() hands over one task (:gen) through visitProducerTasks() (ValueSupplier.java lines 66–70 and 127–129), and that becomes a dependency of show. If the producer is unknown (isKnown() is false), it unpacks the value itself. Taking these dependencies, connecting nodes and deciding the order is the execution plan in part 9.
Gray is what the build script did; blue is what Gradle does when it asks for dependencies. The dotted line is the path of asking "by following the supplier".
Why is it designed this way? dependsOn(gen) only says "order"; it doesn't know which file changes hands. Wiring an output Provider into an input declares "what is passed" (the file path) and "who goes first" (the producer) in one go. You can change the output path without touching the consumer, and you can't forget to put the producer task into the graph.
Example 2-2: reading the output "contents" during configuration
With -PreadEarly, example 2 tries to read the contents of gen's output file with map during the configuration phase.
// -PreadEarly: try to read the producer task's output "contents" during configuration
if (providers.gradleProperty("readEarly").isPresent) {
val content = gen.flatMap { it.outputFile }.map { it.asFile.readText() }
try {
println(content.get())
} catch (e: Exception) {
println("[readEarly] ${e.javaClass.simpleName}: ${e.message}")
}
}
[From the example ex/u08-provider-wiring/project/build.gradle.kts]
=== gradle help -PreadEarly ===
[readEarly] ProducerNotCompletedException: Querying the mapped value of flatmap(provider(task 'gen', class Build_gradle$Gen)) before task ':gen' has completed is not supported. The mapped value may depend on the content produced by task ':gen', so it can only be calculated after task ':gen' has completed.
BUILD SUCCESSFUL in 2s
This is the exception thrown by section 5's TransformBackedProvider.beforeRead(). The source (flatMap)'s producer was made by task(), i.e. content == true, so visitContentProducerTasks() handed over :gen; :gen hadn't run yet (getExecuted() == false), so the read was refused. The source enforces the distinction that a file's path can be known at configuration time, but its contents only after the task has run. ProducerNotCompletedException also carries three suggested fixes (declare it as an input of the other task; get() it inside that task's actions; use locationOnly if you only need the location) (ProducerNotCompletedException.java lines 30–42). In the example I caught the exception and only printed the message, so the build ended successfully.
Easy to get wrong
- Why
flatMapand notmap.gen.map { it.outputFile }would give aProvider<RegularFileProperty>(a Provider inside a Provider).flatMapflattens the inner Provider and gives aProvider<RegularFile>. - This doesn't make
dependsOnunnecessary. For ordering without a file in between (say, "test before deploy") or when you depend on a file you didn't declare as an input, you still usedependsOn. Reading a producer task's output without declaring it as an input makes Gradle warn (MissingTaskDependencyDetector), which we'll see in part 9.
7. Locking values — finalizeValueOnRead, disallowChanges, and right before execution
Provider's "compute later" comes at a price. If a value changes after someone has read it, an earlier reader and a later reader see different values. So Property has three ways to finalize a value.
public void disallowChanges() {
state.disallowChanges();
}
// ...
public void finalizeValueOnRead() {
state.finalizeOnNextGet();
}
// ...
public void implicitFinalizeValue() {
if (state.isUpgradedPropertyValue()) {
// Upgraded properties should not be finalized to simplify migration.
// This behaviour should be removed with Gradle 10.
state.warnOnUpgradedPropertyValueChanges();
} else {
state.disallowChangesAndFinalizeOnNextGet();
}
}
All three only raise flags on state (ValueState). The non-finalized state (NonFinalizedValue) packs its flags as bits into a single byte (flags) (lines 194–202), and the source comment explains why: in a build like Gradle's own there are more than a million ValueState instances, so adding a single reference noticeably increases memory (lines 184–191). The flags actually matter when someone tries to change the value:
@Override
public void beforeMutate(Describable displayName) {
if ((flags & DISALLOW_CHANGES) != 0) {
throw new IllegalStateException(String.format("The value for %s cannot be changed any further.", displayName.getDisplayName()));
} else if ((flags & WARN_ON_UPGRADED_PROPERTY_CHANGES) != 0) {
// ...
This is the check for the not-yet-finalized state with the DISALLOW_CHANGES flag raised. The fully finalized state (FinalizedValue) has a beforeMutate() that throws "The value for %s is final and cannot be changed any further." without asking anything (lines 436–439). set() goes through setSupplier() → assertCanMutate() → state.beforeMutate() (AbstractProperty.java lines 285–288 and 401–403), so this is where it gets stopped. Whether the message contains "is final" tells you which state it was in.
But beforeMutate() doesn't look at FINALIZE_ON_NEXT_GET. finalizeValueOnRead() is just a mark saying "finalize at the next read"; until something reads it, you can still change it. The actual finalization happens on read, when beforeRead() → finalizeOnReadIfNeeded() sees the flag, finalizeNow() computes and fixes the value, and the state switches to FinalizedValue (lines 298–321).
disallowChanges() alone never reaches the finalized state. set() is blocked, but the value is still computed through the supplier chain on every read.
Gradle locks a task's inputs right before it executes
On a task's input and output properties, Gradle itself calls implicitFinalizeValue(). It happens when the task node is executed, right before handing it to the execution engine (part 10).
private ExecutionEngine.@Nullable Result executeFinalizingProperties(LocalTaskNode node) {
TaskProperties properties = node.getTaskProperties();
for (LifecycleAwareValue value : properties.getLifecycleAwareValues()) {
value.prepareValue();
}
try {
return executeTrackingTaskIdentity(node);
// ... finally: cleanupValue() ...
prepareValue() calls the property value's maybeFinalizeValue() (AbstractValidatingProperty.java lines 92–95), which calls implicitFinalizeValue() when the value is a HasConfigurableValue (that is, the Property family) (DefaultPropertyWalker.java lines 141–147). The Javadoc of executeFinalizingProperties() quoted above (lines 304–306) explains the reason: it notifies the task properties of the start of execution "so they may finalize and cache whatever state is required to efficiently fingerprint inputs and outputs or apply validation". Inputs must not change while the execution engine decides "are the inputs the same as last time?", nor while the task actions run.
Example 3: three ways of locking, and changes during execution
// This example was written for this post (it is not Gradle's source)
// See from *when* a Property can no longer be changed.
// ... attempt(label) { … }: runs the block and prints "[label] ok" or the IllegalStateException message ...
// 1) finalizeValueOnRead: only marks it; it is finalized on the first read
val a = objects.property<String>()
println("[a: class] ${a.javaClass.name}")
a.set("one")
a.finalizeValueOnRead()
attempt("a: set before read") { a.set("two") }
println("[a: get] ${a.get()}")
attempt("a: set after read") { a.set("three") }
// 2) disallowChanges: blocks changes on the spot
// ... b: set("x") → disallowChanges() → attempt { b.set("y") } ...
// ... 3) the map example (section 5) ...
// 4) Gradle locks a task's input properties right before execution
abstract class Greet : DefaultTask() {
@get:Input
abstract val who: Property<String>
@TaskAction
fun run() {
println("[greet] hello, ${who.get()}")
// Calling the script's function (attempt) would make this an inner class bound to the script, so use try directly here
try {
who.set("changed")
} catch (e: IllegalStateException) {
println("[greet: set input during execution] ${e.message}")
}
// ... call doLast { } the same way ("[greet: doLast during execution] …") ...
}
}
tasks.register<Greet>("greet") {
who.set("gradle")
// The real object returned by the abstract getter
println("[configure greet] who is ${who.javaClass.name}: $who")
}
[Shortened from the example ex/u08-property-finalize/project/build.gradle.kts]
=== gradle greet ===
[a: class] org.gradle.api.internal.provider.DefaultProperty
[a: set before read] ok
[a: get] two
[a: set after read] The value for this property is final and cannot be changed any further.
[b: set after disallowChanges] The value for this property cannot be changed any further.
[map] hello, second
[configure greet] who is org.gradle.api.internal.provider.DefaultProperty: task ':greet' property 'who'
> Task :greet
[greet] hello, gradle
[greet: set input during execution] The value for task ':greet' property 'who' is final and cannot be changed any further.
[greet: doLast during execution] Cannot call Task.doLast(Action) on task ':greet' after task has started execution.
BUILD SUCCESSFUL in 25s
a: even afterfinalizeValueOnRead(),set("two")worked before the first read, and the value read wastwo. Thesetafter the read failed with the "is final" message.b:disallowChanges()blocked it immediately, and the message has no "is final". That's the red box in the diagram above.- A property without an owner label (one made with
objects.property()) is calledthis property; the task's property is calledtask ':greet' property 'who'— the label attached by section 5's generated getter. - Inside
greet's action, trying to change its own inputwhofailed with "is final". Right before execution,implicitFinalizeValue()raised both flags, and then someone read the value (fingerprinting the inputs, or the action'swho.get()), moving it to the finalized state. - The
doLast { }in the same action was stopped by section 4'sTaskMutator. TheTask.doLast(Action)in the message is exactly the name passed tomutate().
Easy to get wrong
finalizeValueOnRead()does not mean "no more changes from now on". As the name says, it means "finalize on read". If a plugin calls it on an extension property, the user can still change the value in the build script, and it becomes unchangeable from the moment it is first read. To block changes right now, usedisallowChanges(); to compute and lock the value right now, usefinalizeValue().- The first time I ran the example, Gradle couldn't create the
greettask. At first I also called the script's top-levelattempt()function from inside the task action, and it failed withCould not create task of type 'Greet'.→Class Build_gradle.Greet is a non-static inner class.As the message says,Greetbecame an inner class bound to the script class (Build_gradle), and Gradle can't create a task from such a class. That's whyGreetusestrydirectly (see the comment in the code above). How a script is compiled into classes is part 6's territory, so I didn't trace this cause in the source. - "Upgraded properties" are an exception. The first branch of
implicitFinalizeValue()(lines 264–267) does not finalize properties marked@ReplacesEagerProperty(properties that replaced an old plain getter/setter) and only warns if they are changed during execution. The comment says this will be removed in Gradle 10. I did not run this branch in an example.
What this post leaves out
- The bridge between the software model (rule-based model) and the task container (
TaskInstantiator,realize(),discoverTasks()):DefaultTaskContainerFactory.javalines 113–170,DefaultTaskContainer.javalines 586–633 replace()and task rules (addRule, name rules likeclean<Task>):DefaultTaskContainer.javalines 245–285 and 474–496,DefaultNamedDomainObjectCollection.javalines 467–506,CleanRuleinLifecycleBasePlugin- The path for recreating tasks from the configuration cache (
AddTaskBehavior.ASSUME_UNIQUE,createWithoutConstructor()):DefaultTaskContainer.javalines 236–243 and 498–502 — part 11 ListProperty,SetProperty,MapProperty, file properties (DefaultFilePropertyFactory) andconvention():platforms/core-configuration/model-core/…/api/internal/provider/,platforms/core-configuration/file-collections/…/api/internal/file/DefaultFilePropertyFactory.java- The guard against circular evaluation (
EvaluationContext,openScope()), and keepingtoString()from computing the value:platforms/core-configuration/model-core/…/api/internal/provider/EvaluationContext.java - Unsafe-read checks (
disallowUnsafeRead(),PropertyHost.beforeRead()):ValueState.javalines 209–234 - Collecting task class information (
DefaultTaskClassInfoStore— finding@TaskActionmethods, incremental actions):subprojects/core/src/main/java/org/gradle/api/internal/project/taskfactory/ - The full set of input/output annotation handlers (
InputFilePropertyAnnotationHandler, …) and validation:subprojects/core/src/main/java/org/gradle/api/internal/tasks/properties/annotations/
Summary
| Concept | One-line summary |
|---|---|
register |
Only creates a TaskCreatingProvider and puts it on the pending list. No task object, no configuration block run (Register task) |
| Realization | The first time the provider's value is asked for, tryCreate() creates the task and runs the onCreate actions (Realize task) |
| What triggers realization | get/findByName/getByName, resolving command-line names, iteration (not size), all/whenObjectAdded/withType with a block, asking for the producer |
create |
Creates and configures on the spot. @Deprecated at this commit |
configureEach |
Added to the collection's addActions; runs now only on created ones via iteratorNoFlush(). Pending ones get it when created |
withType<T> { } |
Is all(). Creates every pending one and subscribes to the type, so later register calls are created immediately too |
| Task factory | AnnotationProcessingTaskFactory wraps TaskFactory. Only DefaultTask subclasses; @TaskAction enters the action list at creation |
| Action list | A single ArrayList. doFirst at index 0, doLast at the end. TaskMutator blocks changes once execution starts |
map |
Only returns a TransformBackedProvider. Computed at get(), from the source's latest value |
Property.set |
Even a fixed value is held wrapped in a supplier. Put in another Provider and it holds that chain |
ValueProducer |
"Who makes this value?" Output properties know their owning task through attachProducer() |
Ordering without dependsOn |
Input property → getProducer() → TaskProducer → dependency. DefaultTaskInputs.visitDependencies() |
ProducerNotCompletedException |
Reading with map, before the task runs, a value whose contents that task produces |
| Locking | finalizeValueOnRead (on read), disallowChanges (no set from now on), implicitFinalizeValue (right before execution, by Gradle) |
What's next
In this post, show got as far as reporting its reliance on gen as a dependency. What comes next — starting from the requested tasks, following dependencies to collect nodes, deciding the order including mustRunAfter and finalizedBy, and running them spread over several worker threads — is the work graph and the execution plan. How the task picked by section 2's TaskNameResolver becomes a node in DefaultExecutionPlan, and why only one task runs at a time without --parallel, is in part 9: The Task Graph and the Execution Plan — What, in What Order, How Many at Once.
Earlier series covered similar ideas. "Register now, create when needed" is the same idea as BeanDefinition in Spring part 3: Spring also registers a bean's blueprint (BeanDefinition) first and creates the object later. But Spring's default is to create all non-lazy-init singletons up front at the end of refresh() (Spring part 7), while Gradle's default is the opposite: only create what enters the graph. On the value side, Providers resemble ObjectProvider and @Lazy from Spring part 5 in that they "don't look it up now, fetch it later" — the difference is that Gradle's Provider also carries "who makes it" (the producer), and that decides the execution order.
- Part 7: Applying Plugins — From the plugins {} Block to apply()
- Part 4: The Service Registry — the DI Container Inside Gradle
- Part 5: The Build Lifecycle — Where Initialization, Configuration and Execution Split
- Part 10: The Execution Engine and Incremental Builds — How UP-TO-DATE Is Decided
- Part 11: The Build Cache and the Configuration Cache — Two Ways to Reuse Work