Skip to content
Book Cover of Extending Android Builds

4-3: Nested DSL ​

In our journey through the DSL (Domain-specific language) landscape, we've encountered configurations that use a singular block, particularly within the slack test project. However, real-world applications often throw us into the depths of intricate, multi-level nested configurations, such as the Extension of the Android Gradle Plugin (AGP) shown below.

Kotlin
// depth = 0
android {
  // depth = 1
  defaultConfig { 
    // depth = 2
    applicationId = "me.xx2bab.buildinaction.app"
  }
  buildTypes { // NamedDomainObjectContainer
    named("debug") {
      isMinifyEnabled = false
    }
    named("released") { ... }
  }
  ...
}

In this exploration, we'll dive into various multifaceted scenarios for nesting DSLs. We'll start with the single object configurations, examine the mechanics of Kotlin Lambda and Groovy Closure transmissions, and delve into the enigma of the NamedDomainObjectContainer.

You'll find the source code nestled in the slack project. Here's a quick breakdown:

  • The Plugin project resides at slack/plugins/slack-nested-blocks;
  • Tinker with the Plugin configuration at slack/plugin-config/src/main/kotlin/slack-nested-blocks-config.gradle.kts;
  • The Android Application test project, a bearer of the Plugin, resides in slack/app. Remember to activate the appropriate Plugin in app.gradle.kts during testing.

4-3-1: Collection Object Configuration: NamedDomainObjectContainer ​

Though we've brushed over the NamedDomainObjectContainer and the difference of buildTypes{...} between two DSLs (Groovy and Gradle) in sections 2-3, it's time we really got our hands dirty. The essence of NamedDomainObjectContainer is to create a collection within the DSL, tailored for effortless element operations:

  • The elements within are identifiable by name. Manipulation—be it addition, removal, modification, or queries—is facilitated through a suite of APIs. Notably, the resulting set of queries is staying live.
  • Internally, these elements maintain order and uniqueness, standing on the foundation of SortedSet.

Let's dissect this collection further with a piece of sample code.

The Name Convention ​

Leading the NamedDomainObjectContainer parade is the Named interface. This refers to the org.gradle.api.Named interface, which mandates that collection elements should implement the getName() method. The name retrieved through this method is used as the collection's identifier.

Java
public interface Named {
  String getName();
}

For instance, within the context of buildTypes, identifiers like "debug" and "staging" represent the name attribute of the buildType.

Kotlin
buildTypes {
  getByName("debug") {
    isMinifyEnabled = false
  }
  register("staging") {
    isMinifyEnabled = false
  }
}

DSL Configuration ​

Let's extend our current perspective with a hypothetical scenario via the slack notification Plugin: broadcasting build notifications across multiple channels. Our goal is to engineer a DSL configuration, parallel in simplicity to buildTypes, enabling users to easily specify channel parameters:

Kotlin
slackNotification2 {
  ...
  channels {
    register("androidTeam") {
      token.set(slackToken)
      channelId.set(slackChannelId)
    }
    register("mobileTeam") {
      token.set(slackToken2)
      channelId.set(slackChannelId2)
    }
  }
}

At a cursory glance at the configuration design, our next steps become clear: we must uncouple the token and channelId from the original SlackNotificationExtension, placing them in a new class.

Kotlin
abstract class SlackNotificationExtension {
  abstract val enabled: Property<Boolean>
  abstract val message: Property<String>
  abstract val channels: NamedDomainObjectContainer<SlackChannel>
}

interface SlackChannel: Named {
  val token: Property<String>
  val channelId: Property<String>
  
  // If the `Named` interface doesn't meet your requirements, 
  // you could alternatively create a String property called "name", 
  // or deploy the getName() method as a substitute. 
  // val name: String
}

Upon initializing the SlackNotificationExtension, it's delegated to Gradle as a managed type, properties such as channels are getting initialized automatically. And a point to underscore: ensure that collection elements implement Named interface or provide the name property. This practice paves the way for name-based filtering and querying via various APIs:

Kotlin
// Direct return
slackExtension.channels.getByName("name")
// Procures its Provider type - `NamedDomainObjectProvider<T>`
slackExtension.channels.named("name")

Lastly, if you wish to manually create a NamedDomainObjectContainer, you can use project.container(Class<T>) or project.objects.domainObjectContainer(Class<T>). The difference between these two methods is elaborated in Section 4-5.

The DomainObjectCollection ​

Although NamedDomainObjectContainer<T> is frequently utilized in practice,DomainObjectCollection<T> serves as a top-level interface and should be taken into account. There are lots of sub-classes that we often engage with, notably:

  • TaskContainer (accessible via project.tasks) is a direct implementation of NamedDomainObjectContainer<T>.
  • Classes like PluginContainer(accessible through project.plugins) and DependencySet extend from the DomainObjectCollection<T> directly.

The DomainObjectCollection<T> is a direct child of java.util.Collection<T>. Its essence is to provide enhancements over the standard collection methods in the realm of Gradle. This includes adding or removing elements with callback features and auto-updating result sets based on filtering criteria. With DomainObjectCollection having many derived subclasses, including our much-referenced NamedDomainObjectContainer, let's delve into some of the basic APIs that DomainObjectCollection offers.

Kotlin
// As soon as an element is added, 
// this callback will print its name.
slackExtension.channels.whenObjectAdded {
  println(this.name)
}

// The matching(...) method yields a `NamedDomainObjectSet<T>`. 
// This is a live collection that auto-updates based on the filter.
val androidSet = slackExtension.channels
  .matching { it.name.contains("android", ignoreCase = false) }

// It's currently empty because the script hasn't evaluated, 
// so no channels are added.
println("androidSet: " + androidSet.size) 

// Adding a default channel for illustration.
slackExtension.channels.add(object : SlackChannel{
  override val token: Property<String>
    get() = project.objects
      .property<String>()
      .convention("token")
  override val channelId: Property<String>
    get() = project.objects
      .property<String>()
      .convention("id")
  override fun getName(): String { 
    return "androidDefault" 
  }
})

// Now, the androidSet reflects its new size.
println("androidSet: " + androidSet.size)

This live data mechanism might remind you of the Provider paradigm we examined earlier. Furthermore:

  • If you need to filter collections based on subtypes, consider using withType<S>() (provided by the kotlin-dsl Plugin).
  • The method TaskCollection#whenTaskAdded(...) introduced in a prior section is essentially built upon whenObjectAdded(...).
  • There are a few Gradle extension classes that has similar design akin to NamedDomainObjectContainer, such as ExtensionContainer (accessible through project.extension) which provides create(...), getByName(...) and getByType(...).

The discussions in the earlier sections have largely deconstructed the core content of NamedDomainObjectContainer. In conclusion, we've gathered the discussions on all interfaces and selected methods to present a core class diagram for NamedDomainObjectContainer (Figure 4.3.1). It's advisable to revisit this topic while referencing the diagram in conjunction with the API:

Figure 4.3.1: The UML for NamedDomainObjectContainer related classes

4-3-2: Single Object Configuration: Action ​

The power of NamedDomainObjectContainer enabled us to send messages across multiple channels. However, imagine a scenario where every channel broadcasts an identical message. The message attribute of each SlackChannel would be repetitive. What if, akin to the AGP's defaultConfig{}, we could set default attributes, eliminating the need for such redundancy?

Kotlin
slackNotification2 {
  enabled.set(true)
  defaultConfig {
    message.set("built successfully...")
  }
  channels {
    ...
  }
}  

interface DefaultConfig {
  val message: Property<String>
}

Having introduced a fresh DefaultConfig interface, the next logical step is to delve into how nested configurations of single child objects, like defaultConfig{}, can be implemented.

Kotlin Lambdas and Trailing Lambdas ​

Kotlin has a great convention for lambda expressions: when a function's last parameter is another function, the lambda expression can be moved outside of the function call's parentheses. Consider the following:

Kotlin
fun defaultConfig(fileName: String, action: () -> Unit) {...}

slackNotification2 {
  defaultConfig("fileA") {
    ...
  }
}

If there's only one function parameter, it simplifies to:

Kotlin
fun defaultConfig(action: () -> Unit) {...}

slackNotification2 {
  defaultConfig {
    ...
  }
}

This seems close to our goal. However, the method accepts a lambda with no arguments, and we haven't yet configured it for a DefaultConfig instance. By introducing a parameter into our lambda, the structure becomes ideal:

Kotlin
fun defaultConfig(action: (d: DefaultConfig) -> Unit) {...}

slackNotification2 {
  defaultConfig { d ->
    d.message.set("...")
  }
}

We've now crafted a basic structure for the defaultConfig{} configuration. Delving deeper, Kotlin offers a unique lambda type, A.(B) -> C, known as "Function literals with a receiver". This means the lambda:

  • Uses A as the receiver (this = A).
  • Accepts B as an input parameter and returns C.

For the sake of this discussion, if we exclude B and C (and reflect on the previous example), a succinct "type-safe builder" syntax, A.() -> Unit, emerges. Let's incorporate this into our defaultConfig(...) method:

Kotlin
val defaultConfig: DefaultConfig = ...
fun defaultConfig(action: DefaultConfig.() -> Unit) {
  action.invoke(defaultConfig)
}

slackNotification2 {
  defaultConfig {
    message.set("...") 
    // It's equivalent to `this.message.set("...")`
  }
}

So, we've used a Domain Specific Language (DSL) to configure the DefaultConfig object, leveraging Kotlin's higher-order function capabilities.

Another similar approach is to use Kotlin's apply(...) extension, and then a comparable outcome is achieved:

Kotlin
// `apply()` is designed to accept a type-safe builder.
public inline fun <T> T.apply(block: T.() -> Unit): T {
  contract {
    callsInPlace(block, InvocationKind.EXACTLY_ONCE)
  }
  block()
  return this
}

fun defaultConfig(action: (d: DefaultConfig) -> Unit) {...}

slackNotification2 {
  defaultConfig { d ->
    d.apply {
      message.set("...")
    }
  }
}

Groovy's Closure Mechanism ​

There's a notable limitation with the type-safe builder: it exclusively supports Kotlin Gradle DSL. Thus, the Gradle Groovy DSL works in a different way. Let's take Gradle's dependencies{} API as an example:

Java
// org.gradle.api.Project
void dependencies(Closure configureClosure);
Groovy
// build.gradle
dependencies {
  ...
}

Groovy's Closure seems less straightforward than Kotlin's type-safe builder, especially for newcomers. It's not immediately clear what object the "delegate" corresponds to just by inspecting the method's signature. One would need to delve into the source code or accompanying documentation to discover that the above Closure eventually ties back to a DependencyHandler object.

/**
 * ...
 * This method executes the given closure against the {@link DependencyHandler} 
 * for this project. The {@link DependencyHandler} is passed to
 * the closure as the closure's delegate.
 * ...
 */

Embracing Gradle's Action Interface ​

It's evident that Gradle acknowledged the limitations and intricacies of earlier approaches. Enter the org.gradle.api.Action interface, a Java-based mechanism from Gradle, is designed to:

  • Enable single object configuration in both DSL scripts.
  • Support smooth modification of configurations across three languages.

Here's an example showcasing a refined SlackNotificationExtension using this interface:

Kotlin
import org.gradle.api.Action
...

abstract class SlackNotificationExtension @Inject constructor(
  objects: ObjectFactory
) {
  val defaultConfig: DefaultConfig = objects.newInstance(
    DefaultConfig::class.java)

  fun defaultConfig(action: Action<DefaultConfig>) {
    action.execute(defaultConfig)
  }
}

// Gradle Kotlin DSL
slackNotification2 {
  defaultConfig {
    message.set("...")
  }
}

// Gradle Groovy DSL
slackNotification2 {
  defaultConfig {
    message = "..."
  }
}

Let's unravel the magic behind this with three key points:

  1. Capabilities of the Action Interface: The secret sauce behind Action is Gradle's runtime bytecode modification, making it adaptable to varying scripts. For instance, on the Gradle Groovy DSL, Gradle dynamically creates an overloaded function with the same name that takes a Groovy Closure. This necessitates Gradle managing the instantiation of related classes or properties, or explicitly employing utility classes like ObjectFactory to initialize the corresponding objects.

  2. You might wonder why the default constructor has an @Inject annotation and what exactly is the ObjectFactory. The @Inject annotation originates from the Java Dependency Injection standard (JSR-330). This annotation signifies constructors, class attributes, and methods that can be injected. For those familiar with dependency injection frameworks like Guice and Dagger, this should ring a bell. While they are implementations of this standard, they still have slight differences. Gradle, on the other hand, allows certain commonly-used services (utility classes) to be annotated with this label, indicating that they require injection by Gradle. These services include:

    • ObjectFactory
    • ProjectLayout
    • ProviderFactory
    • WorkerExecutor
    • FileSystemOperations
    • ArchiveOperations
    • ExecOperations
    • ToolingModelBuilderRegistry

    Throughout this book, we've used several of these services. For instance, we've used ObjectFactory both in the previous section and here. This utility class enables the creation of various Gradle Custom Types, including the NamedDomainObjectContainer. Notably, its newInstance(...) method can be used to explicitly create our custom Extension objects. By doing so, we achieve runtime optimizations synonymous with Gradle Managed Classes or Properties, which we first introduced in Section 2-4.

  3. Reasoning for the Public Property Declaration: Declaring val defaultConfig: DefaultConfig as public ensures simultaneous support for direct property access, without making it obligatory for users to use an additional block for configurations. (We can also see it in AGP configuration)

    Kotlin
    slackNotification2.defaultConfig.message.set("...")

With the combination of the Action interface and ObjectFactory, you have a one-stop method to ensure compatibility across all languages in script DSLs and Plugins.

In the past, Gradle's Extensions often paired Groovy's Closure with Java's Action interface, especially when Groovy DSL was the only supported script. But with Gradle Kotlin DSL joining the party and the associated compatibility challenges, Action emerged as the best practice. The official documentation echoes this sentiment, advocating the shift from the dual Closure and Action approach to predominantly using Action only.

Nested Configurations with the Action API ​

Building on the above methods, crafting multi-tier nested DSL configurations is a breeze. To demonstrate, let's utilize the Action approach:

Kotlin
interface Package {
  val id: Property<String>
}

abstract class DefaultConfig @Inject constructor(
  objectFactory: ObjectFactory
) {
  abstract val message: Property<String>

  val pkg: Package = objectFactory.newInstance(
    Package::class.java)

  fun pkg(action: Action<Package>) {
    action.execute(pkg)
  }
}

// depth = 0
slackNotification2 {
  // depth = 1 
  defaultConfig {
    message.set("...")
    // depth = 2
    pkg {
      id.set("...")
    }
  }
  ...
}

This example showcases a three-tier nesting, apt for the slack notification configurations.

4-3-3: Passing Function: Kotlin Lambdas and Groovy Closures ​

Using Action for configuring a single object is incredibly handy, eliminating the need for assistance from the APIs of Kotlin and Groovy. However, in reality, Kotlin Lambda and Groovy Closure serve additional purposes within Gradle scripts. Dive with me into a common scenario to see them in action:

Suppose you're integrating a third-party Plugin, while not all build types need this Plugin active. An instance would be when the Dex Packer plugin remains inactive during a local "debug" build.

A typical workaround involves scripting to fetch the input Task name, then deciding the Plugin's activation status:

Kotlin
slackNotification2 {
  enabled.set(if(taskName.contains("debug") false else true))
}

Yet, when the rules become intricate, this method may fall short. Consider this: we've just rolled out the "send to multiple channels" capability in the plugin, so if we don't want the "mobileTeam" to be alerted during a "debug" build, the current Extension won't cut it.

An intuitive solution for a developer would be: Wouldn't directly passing a function into the Extension resolve the issue? Like so:

Kotlin
slackNotification2 {
  ...
  selectChannelsByVariant { variant, channel ->
    !(variant == "debug" && channel == "mobileTeam")
  }
}

This snippet is a model of clarity and flexibility. Crafting this mechanism isn't rocket science either. You just define methods for the necessary Gradle script types upon Kotlin Lambda and Groovy Closure:

Kotlin
internal abstract val kotlinChannelsByVariantSelector: Property<
  ChannelsByVariantSelector>

internal abstract val groovyChannelsByVariantSelector: Property<
  Closure<Boolean>>

// For the Gradle Kotlin DSL
fun selectChannelsByVariant(selector: ChannelsByVariantSelector) {
  kotlinChannelsByVariantSelector.set(selector)
}

// And for the Gradle Groovy DSL
fun selectChannelsByVariant(selector: Closure<Boolean>) {
  groovyChannelsByVariantSelector.set(selector.dehydrate())
}

You'll observe we've enclosed it within Property. When invoking the Task, it's common to merge selectors from both languages:

Kotlin
private fun isTheChannelRequiredByVariant(
  variantName: String,
  channelName: String
) = when {
  kotlinChannelsByVariantSelector.isPresent -> {
    kotlinChannelsByVariantSelector.get()
      .invoke(variantName, channelName)
  }
  groovyChannelsByVariantSelector.isPresent -> {
    groovyChannelsByVariantSelector.get()
      .call(variantName, channelName)
  }
  else -> true
}

In conclusion, by combining the strengths of both Kotlin and Groovy, we've crafted a robust solution to our initial problem.

4-3-4: Hands-on Demo: Multi-Channel Notification, DefaultConfig, and Variant Filtering ​

Recall that through DSL nesting, we addressed three distinct requirements:

  1. Dispatching build notifications to various Slack channels: This was achieved using the NamedDomainObjectContainer which serves as a collection object configuration.
  2. Configuring the association between Variants and specific channels, such as directing Debug builds only to "androidTeam" or routing Release builds to multiple channels: This was made possible by Kotlin Lambda and Groovy Closure.
  3. Permitting users to personalize the DefaultConfig configuration (a DSL nested at multiple levels): Implemented using the Action interface.

Having discussed these components, we've successfully built the SlackNotificationExtension. To wrap it up, we'll illustrate the Plugin configuration and Task logic:

Kotlin
abstract class SlackNotificationPlugin : Plugin<Project> {
  ...
  override fun apply(project: Project) {
    val slackExtension = project.extensions.create(
      "slackNotification2",
      SlackNotificationExtension::class.java
    ).apply {
      enabled.convention(true)
      defaultConfig.message.convention("")
      channels.whenObjectAdded { // Default values setup
        token.convention("")
        channelId.convention("")
        channelMsg.convention("")
      }
    }
    ...
    project.plugins.withType<AppPlugin> {
      ...
      androidExtension.onVariants { appVariant ->
        ...
        // Registering the Task with all prepared parameters
        val taskProvider = project.tasks.register(
          "...",
          SlackNotificationTask::class.java
        ) {
          variantName.set(appVariant.name)
          kotlinChannelsByVariantSelector.set(
            slackExtension.kotlinChannelsByVariantSelector)
          groovyChannelsByVariantSelector.set(
            slackExtension.groovyChannelsByVariantSelector)
          channels = slackExtension.channels
          defaultMessage.set(slackExtension.defaultConfig.message)
          builtArtifactsLoader.set(artifactsLoader)
          apkFolder.set(apkDir)
          notifyPayloadLog.set(logFile)
        }
      }
    }
  }
}   

abstract class SlackNotificationTask : DefaultTask() {

  @get:Input
  abstract val variantName: Property<String>

  @get:Optional
  @get:Input
  abstract val kotlinChannelsByVariantSelector: Property<
    ChannelsByVariantSelector>

  @get:Optional
  @get:Input
  abstract val groovyChannelsByVariantSelector: Property<
    Closure<Boolean>>

  @get:Input
  var channels: NamedDomainObjectContainer<SlackChannel>? = null

  @get:Input
  abstract val defaultMessage: Property<String>
  
  ...
  
  @TaskAction
  fun notifyBuildCompletion() {
    ...
    // Iterate all channels and send one by one
    channels!!.forEach { slackChannel ->
      // Pre-check before we post
      if (!isTheChannelRequiredByVariant(
          variantName.get(),
          slackChannel.name
        )) { 
        return@forEach
      }
      
      val customMsg = slackChannel.channelMsg.get().ifBlank {
        defaultMessage.get()
      } + "\n $baseContent"
      val (code, responseBody) = postOnSlack(
        slackChannel.token.get(),
        slackChannel.channelId.get(),
        customMsg
      )
      ...
    }
  }
  ...
}

As for the result, if you execute the command ./gradlew clean :app:assembleAndNotifyDebug, you'll observe a single log entry:

Bash
[Pg][Slack]: sending to androidTeam

In contrast, executing ./gradlew clean :app:assembleAndNotifyRelease presents two log entries:

Bash
> [Pg][Slack]: sending to androidTeam
> [Pg][Slack]: sending to mobileTeam

4-3-5: In Summary ​

This section explains solutions to tangible challenges, simplifying Plugin configurations for users. For developers, this means bolstered confidence in Plugin creation, ensuring compatibility with both Groovy and Kotlin scripts.