skip to content

Walk through authoring a custom extensible named-collection DSL with objects.domainObjectContainer: what does the element type need, how do you expose it, and what makes the nested block work?

level: seniorimportance: must knowfreq 35%

answer

  1. element type needs a name (ctor arg)
  2. abstract + Property fields = managed + lazy
  3. objects.domainObjectContainer(Type)
  4. extensions.add(name, container) makes the block
  5. configureEach + convention for defaults

basics

~20 s

Define an abstract element type with a name constructor param and Property-typed fields, create the container with objects.domainObjectContainer(Type), add it as an extension, and Gradle auto-generates the myThings { foo { } } nested DSL keyed by name.

solid answer

~50 s

First design the element type: an `abstract class Foo(val name: String)` (or a constructor that takes the name) with `Property`/`ListProperty`-typed attributes so values stay lazy. Gradle must be able to instantiate it by name, so either provide a single-`String` constructor (Gradle injects the name and constructs a *managed* object via `ObjectFactory`) or supply a `NamedDomainObjectFactory`. Then create the container in your plugin's `apply`: `val foos = objects.domainObjectContainer(Foo::class.java)`. Expose it by adding it as an extension: `project.extensions.add("foos", foos)` (or a typed extension holding it). That registration is what makes the nested DSL work — Gradle generates a `foos { }` block where each unknown name (`bar { }`) triggers `register`/configure of a `Foo` named `bar`. Add conventions with `foos.configureEach { someProp.convention(...) }` and let consumers wire elements into tasks via `named(...)` providers. The element's properties being lazy `Property` types is what lets you wire them into task inputs without eager realization.

code

kotlin · 18 lines
kotlin
abstract class ServiceSpec(val name: String) {
    abstract val image: Property<String>
    abstract val port: Property<Int>
}

class ServicesPlugin @Inject constructor(private val objects: ObjectFactory) : Plugin<Project> {
    override fun apply(project: Project) {
        val services = objects.domainObjectContainer(ServiceSpec::class.java)
        services.configureEach { port.convention(8080) }
        project.extensions.add("services", services)
    }
}

// build.gradle.kts:
// services {
//     register("web") { image.set("nginx") }
//     register("db")  { image.set("postgres"); port.set(5432) }
// }

go deeper

for a junior

Know that you create a container with objects.domainObjectContainer and add it as an extension to get a named DSL.

for a middle

Explain the name-constructor and Property-field requirements and that extensions.add wires the DSL block.

for a senior

Walk the full loop: type design, managed instantiation, exposure, configureEach conventions, and Property-based task wiring.

for a principal

Treat the container's element type as a public API contract, governing its evolution, defaults policy, and configuration-cache safety across consumers.

## Step 1 — Design the element type The element must satisfy the container contract: a unique, fixed `name`. Make it `abstract` with `Property`-typed fields so Gradle generates a managed implementation and so values are lazy: ```kotlin abstract class ServiceSpec(val name: String) { abstract val image: Property<String> abstract val port: Property<Int> abstract val env: MapProperty<String, String> } ``` Gradle needs to construct `ServiceSpec` *by name*. Two options: - **Single-String constructor** (shown): Gradle's `ObjectFactory` instantiates a managed subclass, injecting the name and materializing the abstract `Property` getters. This is the modern, low-boilerplate path. - **NamedDomainObjectFactory**: pass `objects.domainObjectContainer(Type) { name -> ... }` to control construction yourself. ## Step 2 — Create the container ```kotlin val services = objects.domainObjectContainer(ServiceSpec::class.java) ``` `ObjectFactory` is injected into your plugin/extension (`@Inject` constructor or `project.objects`). ## Step 3 — Expose it as an extension This is the step that *creates the DSL*: ```kotlin project.extensions.add("services", services) ``` Registering the container as a named extension makes Gradle generate a configuration block. In a build script: ```kotlin services { register("web") { image.set("nginx"); port.set(80) } register("db") { image.set("postgres"); port.set(5432) } } ``` Each call inside the block creates/configures a named `ServiceSpec`. (The bare `web { }` shorthand resolves to a `register`/`maybeCreate` of that name depending on context.) ## Step 4 — Conventions and bulk rules Give defaults lazily so users can override: ```kotlin services.configureEach { port.convention(8080) env.put("TZ", "UTC") } ``` `convention(...)` sets a fallback that user `set(...)` overrides — current/future elements covered, lazily. ## Step 5 — Wire elements into tasks Because attributes are `Property`s, you wire them into tasks without realizing eagerly: ```kotlin services.configureEach { val spec = this project.tasks.register("deploy${spec.name.replaceFirstChar { it.uppercase() }}", DeployTask::class.java) { image.set(spec.image) port.set(spec.port) } } ``` (For strict configuration-cache safety, capture provider values rather than the `Project` inside execution.) ## Why each piece matters - **name in the type** → satisfies the container identity contract and enables the keyed DSL. - **abstract + Property** → managed instantiation by `ObjectFactory` and lazy values. - **extensions.add** → the nested DSL block appears; without it there's no syntax. - **configureEach + convention** → defaults that compose lazily and apply to future elements. That's the complete loop: a first-class, extensible, lazy named-collection DSL the way Gradle's own `sourceSets` is built.

  • Why must the element type be abstract with Property-typed fields?
    So Gradle's ObjectFactory can generate a managed implementation (materializing the abstract getters) and so the values are lazy Providers that can be wired into tasks and overridden via convention/set without eager realization.
  • What single step actually makes the `services { ... }` nested DSL appear?
    Registering the container as a named extension via project.extensions.add("services", container). Gradle generates the configuration block keyed by element name from that registration.
  • How does Gradle know how to instantiate each element by name?
    Either the type has a single-String constructor (Gradle injects the name and builds a managed object), or you pass a NamedDomainObjectFactory to domainObjectContainer to construct instances yourself.

saying these in an interview costs you the question

  • Using plain mutable fields instead of Property types — loses laziness and managed instantiation.
  • Forgetting to add the container as an extension and expecting the DSL block to appear.
  • Giving the element type a constructor that doesn't accept the name, breaking managed creation.

context