Optimizing Gradle Task Management with Kotlin DSL and buildSrc
Published on 11 July 2025
- Centralizing Configurations with
allprojectsandsubprojects - [source,plantuml]
- @enduml
- [source,kotlin]
- }
- [source,kotlin]
- abstract class ReportJbakeFunctionalTestsTask : AbstractJbakeExecTask() { init { description = "Opens the functional test report in Firefox." reportRelativePath = "build/reports/tests/functionalTest/index.html" } }
- [source,kotlin]
- tasks.register\<ReportJbakeTestsTask\>("reportBuildSrcTests") { // The path is already defined in the class, it will point to buildSrc reports }
Automating build tasks is crucial for any software project, and Gradle, with its Kotlin DSL, offers exceptional flexibility. During our conversation, we explored how to centralize and reuse build logic, particularly for test reports, by leveragingallprojects, buildSrc, and custom tasks in Kotlin.
Centralizing Configurations with allprojects and subprojects
The blocks`allprojects` et `subprojects`in your`build.gradle.kts`root are fundamental for applying common configurations across your multi-module project.
*allprojects { … }: Applies the configuration to theroot project and all its sub-projects. Ideal for defining a`group`, a`version`, or common`repositories`. *subprojects { … }: Applies the configurationonly to sub-projects, excluding the root project. Perfect for applying module-specific plugins (such as`java` ou kotlin-jvm) or common dependencies to your libraries.
Here is an illustrative example:
// build.gradle.kts (projet racine)
plugins {
base // Appliqué au projet racine
}
allprojects {
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
}
tasks.withType<org.gradle.api.tasks.testing.Test> {
useJUnitPlatform() // Configuration commune des tests pour tous les projets
}
}
subprojects {
apply(plugin = "java")
apply(plugin = "org.jetbrains.kotlin.jvm")
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}
}
\==buildSrc: The Swiss Army Knife of Build Logic
When your task logic becomes complex or needs to be reused,buildSrcis the preferred solution. It is a special Gradle module that is compiled before the main build scripts, making its classes available on the classpath of your entire build.
\=== Why use`buildSrc`for Tasks?
-
Reusability: A task defined in`buildSrc`can be applied to any project in the build.
-
Organization: Centralizes build code, making it cleaner and more maintainable.
-
Type Safety and Autocompletion: Kotlin code in`buildSrc`is compiled, providing error checking and IDE autocompletion, improving the development experience.
\=== Flow Diagram`buildSrc`(PlantUML Code)
To generate this diagram, copy the code below and paste it into a tool supporting PlantUML.
[source,plantuml]
@startuml skinparam handwritten true skinparam monochrome true
rectangle "Gradle Build Process" { component "buildSrc" as BS { file "MyCustomTask.kt" as T file "MyConventionPlugin.kt" as P } component "Root Project" as RP component "Subproject A" as SA component "Subproject B" as SB }
T --\> P : "is defined in" P --\> RP : "is applied to" P --\> SA : "is applied to" P --\> SB : "is applied to"
RP --|\> SA : "contains" RP --|\> SB : "contains"
RP -up-\> BS : "depends on (for build logic)" SA -up-\> BS : "depends on (for build logic)" SB -up-\> BS : "depends on (for build logic)"
note right of T Custom task classes (e.g., OpenTestReportTask) end note
note right of P Convention plugins that register tasks end note
@enduml
\== Creating Abstract Report Tasks
To manage test reports, we designed a modular approach using an abstract task class in`buildSrc`. Depending on your needs, this class can inherit from`DefaultTask` ou de Exec.
\=== Abstract Task`OpenTestReportTask`(inheriting from`DefaultTask`)
This approach is recommended if you need custom Kotlin logic that interacts with the file system or other Gradle APIs, and then launches an external command.
[source,kotlin]
package com.yourpackage
import org.gradle.api.DefaultTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.TaskAction import java.io.File
abstract class OpenTestReportTask : DefaultTask() {
----
init {
group = "verification"
description = "Opens a test report in Firefox."
dependsOn("check") // Assure que les rapports sont générés
}
@get:Input
abstract var reportPath: String
@TaskAction
fun openReport() {
val separator = File.separator
val reportFile = project.layout.projectDirectory.asFile.toPath()
.resolve(reportPath.replace("/", separator))
.toAbsolutePath()
.toFile()
if (!reportFile.exists()) {
logger.warn("Report file does not exist: $reportFile. Ensure 'check' ran.")
return
}
project.exec {
commandLine("firefox", "--new-tab", reportFile.absolutePath)
}
logger.lifecycle("Opened test report: ${reportFile.absolutePath}")
}
----
## }
\=== Concrete Implementations
These classes inherit from the abstract task and define the specific report path.
## [source,kotlin]
package com.yourpackage
abstract class ReportUnitTestsTask : OpenTestReportTask() { init { description = "Opens the unit test report in Firefox." reportPath = "build/reports/tests/test/index.html" } }
package com.yourpackage
## abstract class ReportFunctionalTestsTask : OpenTestReportTask() { init { description = "Opens the functional test report in Firefox." reportPath = "build/reports/tests/functionalTest/index.html" } }
\=== Task Registration
In the`build.gradle.kts`of your root project:
## [source,kotlin]
## tasks.register\<com.yourpackage.ReportUnitTestsTask\>("reportTests") {} tasks.register\<com.yourpackage.ReportFunctionalTestsTask\>("reportFunctionalTests") {}
\=== UML Diagram of Report Tasks (PlantUML Code)
Copy the code below and paste it into a tool supporting PlantUML.
## [source,plantuml]
@startuml skinparam handwritten true skinparam monochrome true
abstract class DefaultTask { }
abstract class Exec { \+ commandLine(args: String...) \+ exec() }
abstract class OpenTestReportTask extends DefaultTask { \+ group: String = "verification" \+ description: String \+ dependsOn("check") \+ abstract reportPath: String \+ openReport() : void }
abstract class AbstractJbakeExecTask extends Exec { \+ group: String = "verification" \+ description: String \+ dependsOn("check") \+ abstract reportRelativePath: String \+ exec() : void }
class ReportUnitTestsTask extends OpenTestReportTask { \+ reportPath: String = "build/reports/tests/test/index.html" }
class ReportFunctionalTestsTask extends OpenTestReportTask { \+ reportPath: String = "build/reports/tests/functionalTest/index.html" }
class ReportJbakeTestsTask extends AbstractJbakeExecTask { \+ reportRelativePath: String = "build/reports/tests/test/index.html" }
class ReportJbakeFunctionalTestsTask extends AbstractJbakeExecTask { \+ reportRelativePath: String = "build/reports/tests/functionalTest/index.html" }
OpenTestReportTask \<-- ReportUnitTestsTask OpenTestReportTask \<-- ReportFunctionalTestsTask
AbstractJbakeExecTask \<-- ReportJbakeTestsTask AbstractJbakeExecTask \<-- ReportJbakeFunctionalTestsTask
DefaultTask \<|-- OpenTestReportTask Exec \<|-- AbstractJbakeExecTask DefaultTask \<|-- Exec
## @enduml
\== Abstract Task`AbstractJbakeExecTask`(inheriting from`Exec`)
If your task mainly consists of executing an external command with variable arguments, inheriting directly from**Exec**is more direct.
## [source,kotlin]
package com.yourpackage
import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Input import java.io.File
abstract class AbstractJbakeExecTask : Exec() {
init {
group = "verification"
description = "Opens a Jbake project report in Firefox."
dependsOn("check")
}
@get:Input
abstract var reportRelativePath: String
override fun exec() {
val separator = File.separator
val reportFile = project.layout.projectDirectory.asFile.toPath()
.resolve(reportRelativePath.replace("/", separator))
.toAbsolutePath()
.toFile()
if (!reportFile.exists()) {
logger.warn("Report file does not exist: $reportFile. Ensure 'check' ran.")
return
}
commandLine("firefox", "--new-tab", reportFile.absolutePath)
logger.lifecycle("Attempting to open report: ${reportFile.absolutePath}")
super.exec() // Appelle la méthode exec() de la super-classe Exec
}
}
\=== Concrete Implementations for`Exec`
[source,kotlin]
package com.yourpackage
abstract class ReportJbakeTestsTask : AbstractJbakeExecTask() { init { description = "Opens the Jbake unit test report in Firefox." reportRelativePath = "build/reports/tests/test/index.html" } }
package com.yourpackage
abstract class ReportJbakeFunctionalTestsTask : AbstractJbakeExecTask() { init { description = "Opens the functional test report in Firefox." reportRelativePath = "build/reports/tests/functionalTest/index.html" } }
\== Task Visibility`buildSrc`
The tasks and classes you define inbuildSrcare:
*Visible and usableby all projects in your main build (root and sub-projects). This is why you can use`com.yourpackage.ReportUnitTestsTask`in`allprojects { … }`. *Not executabledirectly as buildSrc tasks (for example,gradle :buildSrc:reportTests`would not work if the task is not registered specifically in`buildSrc/build.gradle.kts).`buildSrc`is a compilation module, not an executable application module for these main build tasks.
\=== Running a report for`buildSrc`tests itself
Si buildSrc`has its own tests and generates reports, you can register a report task directly in`buildSrc/build.gradle.kts:
[source,kotlin]
plugins { kotlin-jvm }
repositories { mavenCentral() }
tasks.withType\<Test\> { useJUnitPlatform() reports.html.outputLocation.set(layout.buildDirectory.dir("reports/tests")) }
import com.yourpackage.ReportJbakeTestsTask // Import your task class
tasks.register\<ReportJbakeTestsTask\>("reportBuildSrcTests") { // The path is already defined in the class, it will point to buildSrc reports }
You can then run:`./gradlew :buildSrc:test`followed by`./gradlew :buildSrc:reportBuildSrcTests`.
By adopting these practices, you will build Gradle Kotlin DSL build systems that are not only powerful, but also incredibly modular, maintainable, and easy to understand.