Sharing providers and closures between files

Hello,

In my current build-logic, I have scripts that share providers and closures. These are created in script A, and all of them are consumed by scripts B and C. I am currently using project.ext to share them, which I know is an outdated practice, so I am looking for a better alternative.

These three files used to be a single enormous script, but I split them to improve readability and adhere to best practices.

I have seen this old post about using Java classes, but I am not sure if that is still the recommended approach or if Gradle provides more modern tools to achieve this.

./build-logic/src/main/
┣ A (creates providers/closures)
┣ B (consumes providers/closures)
â”— C (consumes providers/closures)

Thanks in advance.

That post you linked to is a largely different situation.

Can you share a concrete example of your code, so that it is clear what you have there? I for example have to idea what exactly you mean by “sharing a provider”. For “sharing a closure” just have it in some extra class, no matter in which language you write it, Java, Groovy, Kotlin, Scala, whatever JVM language you prefer, and then you can use that in your other files.

Thank you for the reply.

My initial explanation was too abstract and lacked a concrete context. To clarify, my goal is to find the modern, idiomatic way to “share” Providers and Closures between different Gradle scripts acting as convention plugins in my build-logic.

Right now, I am using the legacy project.ext to expose a map of providers and closures from Script A to be consumed by other Scripts.

Here is a simplified example of my current approach:

Script A (The Provider/Closure Creator):

Object propsLocal = gradle.ext.propsLocal // from "local.properties"

Provider<Boolean> eMacProv = providers.systemProperty('os.name').map {
    it.toLowerCase().contains('mac')
}
Provider<String> macDonoProv = project.providers.provider { propsLocal.getProperty('mac.dono') }
Provider<String> macSedeProv = project.providers.provider { propsLocal.getProperty('mac.sede') }
Provider<String> macDirProv = project.providers.provider { propsLocal.getProperty('mac.dir') }
Provider<String> nomeAppProv = providers.gradleProperty('projNome')
Provider<String> buildXDirProv = providers.gradleProperty('buildDirXcode')
Provider<String> vmDirProv = macDirProv.zip(nomeAppProv) { String dir, String app ->
    new File(dir, app).path.replace(File.separator, '/')
}
Provider<String> simuladorUsualProv = project.providers.provider {
    propsLocal.getProperty('simulador.ios')
}

Closure<List<String>> comandoRemoto = { List<String> cmds ->
    final Map<String, String> vmCmds = [
        killall: '/usr/bin/killall',
        rm: '/bin/rm',
        xcrun: '/usr/bin/xcrun',
    ]
    
    List<String> cmdRemotos = cmds.collect { String arg -> vmCmds.getOrDefault(arg, arg) }
    
    return [ 
        'ssh', "${macDonoProv.get()}@${macSedeProv.get()}", 
        "\"cd \'${vmAppDirProv.get()}\' && ${cmdRemotos.join(' ')}\""
    ]
}

project.ext.x = [
    auxs: [
        comandoRemoto: comandoRemoto
    ],
    provs: [
        eMacProv: eMacProv,
        nomeAppProv: nomeAppProv,
        vmDirProv: vmDirProv,
        simuladorUsualProv: simuladorUsualProv,
        buildXDirProv: buildXDirProv
    ],
    cons: [
        workingDir: '/Users/user/project/path/'
    ]
]

Script B (The Consumer):

plugins.apply(libs.plugins.buildLogicA.get().getPluginId())

Object x = project.ext.x

Provider<String> vmXcodeDirProv = x.provs.vmDirProv.zip(x.provs.buildXDirProv) { 
    String dir, String bDir -> new File(dir.toString(), bDir).path.replace(File.separator, '/')
}
Provider<Directory> xcDirProv = project.providers.provider { 
    project.rootProject.layout.projectDirectory.dir(x.provs.buildXDirProv.get()) 
}

tasks.register('cleanMac', Exec) {
    description = 'Limpa os arquivos de build do Xcode para iOS.'
    group = 'MacOs Controller'
    workingDir(x.cons.workingDir)
    outputs.upToDateWhen { false }

    doFirst {
        String simulador = x.provs.simuladorUsualProv.get()
        String dirXc = x.provs.eMacProv.get() ? 
            xcDirProv.get().asFile.absolutePath : vmXcodeDirProv.get()

        commandLine x.auxs.comandoRemoto([
            'killall', 'Xcode', '|| true &&',
            'xcrun', '-k', '|| true &&',
            'rm', '-rf', "\'${dirXc}\'", '|| true'
        ])
    }
}

Given this structure, what is the recommended Gradle API or architectural pattern to replace this project.ext map? Should I register a custom Extension interface, use static utility classes as you mentioned, or perhaps create custom Task types and pass these providers as inputs? Is there any other alternative that I’m not aware of?

To share logic, like the closures, I would indeed just put them as methods on some class in your build logic project, no real need to expose them as properties of something.

To share those providers, yes, an extension class that you add to the project is usually the way to go.

And if you have tasks with custom logic, then creating a dedicated task class for them is usually the best way, yes.

Btw. your doFirst action you showed is also not a good idea.
Because you change the configuration of the task at execution phase.
You should practically never change the configuration of a task after configuration phase.

Thank you for the clear breakdown!

I will definitely refactor my build-logic following that approach to finally get rid of project.ext. As you could see, I am reading the local.properties file inside settings.gradle and sharing it across the build using gradle.ext.propsLocal. Since we are moving away from project.ext, I assume using gradle.ext is also considered a legacy or suboptimal practice. What is the modern, configuration-cache-friendly way to expose these local properties to my convention plugins?

Regarding your comment about the doFirst block being a bad idea, could you elaborate a bit on the core reason behind it?

Is it primarily because calling commandLine inside a doFirst mutates the task’s configuration while the build is already in the execution phase? Or is there also an issue with how and when I am resolving those providers/closures inside that block?

I want to make sure I fully understand the lifecycle implications before rewriting the tasks.

Thanks again for the help!

I assume using gradle.ext is also considered a legacy or suboptimal practice.

Any usage of ext / extra property always was and still is a bad practice and usually a work-around for not doing something in a better way and to quote a former Gradle employee: “every time you use it you should feel dirty”. :slight_smile:

What is the modern, configuration-cache-friendly way to expose these local properties to my convention plugins?

Well, as we established, register an extension that exposes the values.
Or - especially if you want to avoid reading the file once for each project - have a shared build service that does the file parsing and provides the values.
For easier accessibility you could still have an extension that provides the values that is fed from the build service.

could you elaborate a bit on the core reason behind it?

More than “don’t change the configuration of a task at execution phase”? o_O
Not sure what information you are missing.
Configuration phase if for configuring things, execution phase is for executing things.
At execution phase you should practically never change the configuration of the task.
Especially when changing inputs or outputs this is very bad as then up-to-dateness or cache fingerprints are affected.
But even for non-inputs / -output this is discouraged.

Or is there also an issue with how and when I am resolving those providers/closures inside that block?

That block is execution phase, and at execution phase you can get() all providers you need.
Execution phase is actually the only phase where you should ever get() (or similar) a provider,
because at execution phase configuration should not change anymore, so the value you get is the actual final one. … except of course you change configuration at execution phase, which you should not do.

Thank you for the detailed explanation. I had read that quote and was already feeling dirty since then.

Following your advice, I have completely refactored my build-logic and eradicated 100% of the ext properties from my project. I created a dedicated Extension abstract class XcConfig to hold the providers, moved the logic to utility methods, and used a ValueSource to parse the local.properties file in a configuration-cache-friendly way.

Now, I am addressing the final issue you pointed out: refactoring my Exec tasks to remove commandLine from inside the doFirst block.

Since the construction of my remote commands relies heavily on Providers (which, as you mentioned, should ideally have their .get() called only at execution time), I noticed that configuring the traditional commandLine or args properties directly during the configuration phase isn’t very provider-friendly without breaking laziness.

I currently have no planned approach to respect the configuration phase while keeping the lazy evaluation. I now join the group of people who wish that commandLine would natively unwrap providers.

I’m open to any suggestions you’d like to share.

Use an argumentProvider. :slight_smile:
And if the argument provider has the providers declared as input properties, you even automatically get implicit task dependencies.

For example:

val foo by tasks.registering
val bar by tasks.registering(Exec::class) {
    executable = "echo"
    argumentProviders.add(
        object : CommandLineArgumentProvider {
            @get:Input
            val baz = foo.map { "bam" }

            override fun asArguments() = listOf(baz.get())
        }
    )
}

If you now execute bar it automatically runs foo first as the Provider in baz carries the task dependency and is declared as input and the command is called with the result of the Provider safely evaluated only when necessary.

The executable property also doesn’t natively unwrap providers. I initially tried to keep it completely lazy by passing a mapped provider to it, but the build failed trying to execute fixed(class java.lang.String, ssh). It strictly expects a resolved String for the executable at configuration time and just called .toString() on my provider.

Obviously, this is the expected behavior, but I had planned for the comandoRemoto() closure to resolve the command name (bash or ssh) based on eMac.

Since eMac is defined by providers.systemProperty('os.name').map { it.toLowerCase().contains('mac') }, I have no idea how harmful it would be to set straight executable = xcConfig.eMac.get() ? 'bash' : 'ssh'. Everything else is pretty much resolved.

I’d say the OS you are running on should not change during the build, so it should probably be safe to check it at configuration time.

With the big propertyization it will hopefully also cope with a provider.

If you currently need the executable lazy, don’t use a task of type Exec, but use exec { ... } from anExecOperations.

Alright! Unless my laptop undergoes a mid-build existential crisis and decides to turn into a Mac, the OS is definitely not changing during the build.

Thank you so much for your patience, the detailed explanations, and for pointing me toward ExecOperations. I will stick with the eager OS check for the standard Exec tasks to keep the cache happy, and I’ll start evaluating my codebase to migrate the fully lazy, complex executions to custom tasks using anExecOperations.

Thanks again for all the help!