Add task to project configured using property

I would like to write a plugin that adds tasks to some subprojects, where the targets are configured as properties. I would like to configure them as follows:

toolSetups {  // an extension I add to the projects, of type NamedDomainObjectContainer

    someTool {  // an element of the container
        subproject = project(':some-subproject')  // 'subproject' is a Property<Project>
        outputFile = file('some/file/path')  // Property<File>
    }
    someOtherTool {  // another element of the container
        subproject = project(':some-other:subproject')
        outputFile = file('some/other/file/path')
    }

}

Let’s say that outputFile is produced by a task of type ProduceOutput, based on an input from the subproject (e.g. a task in it). I would like to add an instance of ProduceOutput under the subproject itself, as it “feels” like it belongs there. I tried to do the following:

toolSetups.all {
    subproject.get().tasks.register('produceOutput', ProduceOutput)
}

The problem is that when the element is added to the container, the value of the subproject property is not yet available and Gradle will issue an error. I’m guessing that the values set by the closure are set after the ToolSetup is added to the container, i.e. the flow is “create object”, “add to container”, “call closure”, not “create object”, “call closure”, “add to container”.

Is there any way to ensure that the values of the properties are set before the object is added to the container? I’m guessing this would involve passing the values to the properties as constructor arguments, but can this be done while keeping the proposed DSL?

I have read up on Properties and that they have a finalizeValue() function. I’m guessing I would somehow need it to disallow changes to the subproject property, if I’m going to be adding tasks to it. Is there any way to get notified when a value is finalized? (I searched but couldn’t really find one). This could be the moment I could add the ProduceOutput task to the finalized subproject.

I’m currently thinking of adding the tasks to the main project, where I apply the plugin and configure the toolSetups:

toolSetups.all { toolSetup ->
    tasks.register("produceOutputFrom${toolSetup.name}", ProduceOutput) {
        projectWithInput = toolSetup.subproject
    }
}

projectWithInput is a Property<Project> of ProduceOutput. This way ProduceOutput can register the task from the configured subproject as a dependency.

The user would see the tasks under the main project, not under each subproject, but at least I could keep the DSL.

You should neither do what you originally intended, as that is cross-project configuration which couples projects, thus is bad practice, and works against more advanced Gradle features.

But you should probably also not do the second option, depending on what you do with that project reference. If you for example get the output of some task, that is even worse than the first way, as doing so is unsafe and should not be done as can be read about at Sharing outputs between projects.

Why is the coupling of projects a bad thing, though? These are projects that are part of a multi-project build. They would be configured from the root project (though maybe this wasn’t obvious in the initial post). This is more or less what a subprojects block does and how I’ve seen it done to apply the same plugins to all subprojects, for example.

Yes, and subproject { ... } or allprojects { ... } are the same bad practice.
Cross-project configuration hinders some more advanced features like project isolation, parallel execution, configuration on demand, …
And besides that it makes builds harder to read, and harder to maintain.
The idiomatic and recommended approach to have shared build logic is to have convention plugins that are applied where their effect should be directly.