ADR-0006 - Use of Provider APIs in Gradle#
Date#
2024-06-21
Context#
Modern best practices when developing a Gradle plugin are to use lazy types (ConfigurableFileCollection, Provider API, domain object containers) when defining configurable parts of a plugin (tasks, extensions, domain objects). The Provider API provides a consistent way to set conventions, wire related configuration together (extension <- domain object <- task) and avoid evaluation ordering problems.
The Gradle codebase has evolved over time and has a mixture of simple getter/setter methods, Provider API and things in between. It can be hard to follow best practices because Gradle provided types are reused in unexpected ways and extended by third party plugins.
Given these constraints, we cannot always follow best practices. This ADR proposes the way we should handle the use of Provider APIs in the gradle/gradle codebase.
Decision#
Types of properties that should not use lazy types#
The guidelines below do not apply to all properties. There are cases where the lazy types should not be used:
Non-calculated values#
These are inappropriate uses of lazy types:
class Example {
Property<String> getSomeProperty()
Example() {
getSomeProperty().set("value")
getSomeProperty().disallowChanges()
}
}
class Example2 {
Provider<String> getSomeProperty() {
return project.provider(() -> "value")
}
}
Nested values#
This is an inappropriate use of lazy types:
interface NestedType {
Property<String> getSomeProperty()
}
class Example {
Property<NestedType> getNestedProperty()
}
This is unnecessary because users will have trouble creating instances of NestedType
and merging different instances of NestedType
. It's also more awkward for users to access the properties in the nested property.
If the nested type is a managed type (Gradle can generate its implementation), you can define a nested property with:
interface NestedType {
Property<String> getSomeProperty()
}
class Example {
@Nested
NestedType getNestedProperty()
}
interface NestedType {
Property<String> getSomeProperty()
void notManaged()
}
abstract class DefaultNestedType implements NestedType {
...
}
class Example {
private final NestedType nested
Example(ObjectFactory objects) {
this.nested = objects.newInstance(DefaultNestedType.class)
}
@Nested
NestedType getNestedProperty() {
return nested;
}
}
You should prefer to use managed types when possible.
Identity information#
This is an inappropriate use of lazy types:
Like above, this is an immutable part of the identity of the domain object and cannot be changed.
Properties in entirely new classes#
When developing an entirely new class (task, extension, domain object, etc), the API should consist of managed lazy properties.
Preferably, these new classes should be 100% managed and have their implementation generated by Gradle at runtime.
It's acceptable for implementation classes to be written to fit into existing code, but new classes must not instantiate managed properties or implement getters manually.
This is preferred:
This is acceptable:
public interface NewThing {
Property<String> getSomeProperty()
}
abstract class DefaultNewThing implements NewThing {
// special logic to integrate with something existing
}
or
public abstract class NewThing {
public abstract Property<String> getSomeProperty()
// special logic to integrate with something existing
}
This is not acceptable:
public interface NewThing {
Property<String> getSomeProperty()
}
abstract class DefaultNewThing implements NewThing {
private final Property<String> someProperty
DefaultNewThing(ObjectFactory objects) {
someProperty = objects.property(String.class)
}
public Property<String> getSomeProperty() {
return someProperty
}
}
Note that managed classes like these need to be instantiated via ObjectFactory so that runtime decorations are applied. Failure to do this will cause strange usability problems in the Groovy DSL.
New properties in an existing class#
When adding a property to an existing class (task, extension, domain object, etc), the API should consist of managed lazy properties. However, some exceptions need to be made to keep backwards compatibility with existing builds.
This is preferred when the implementation type is internal:
public interface ExistingThing {
String getOtherProperty()
void setOtherProperty(String s)
Property<String> getSomeProperty()
}
abstract class DefaultExistingThing implements ExistingThing {
private String otherProperty
public String getOtherProperty() ...
public void setOtherProperty(String s) ...
// NOTE: No direct implementation of getSomeProperty
}
This is acceptable when the implementation type is public and/or has been extended by something outside gradle/gradle:
public interface ExistingThing {
String getOtherProperty()
void setOtherProperty(String s)
Property<String> getSomeProperty()
}
abstract class DefaultExistingThing implements ExistingThing {
private String otherProperty
private final Property<String> someProperty
DefaultNewThing(ObjectFactory objects) {
someProperty = objects.property(String.class)
}
public String getOtherProperty() ...
public void setOtherProperty(String s) ...
public Property<String> getSomeProperty() {
return someProperty
}
}
It is not acceptable to introduce new properties on a task, extension or domain object that use plain getters and setters. It is also not acceptable to add setters that take a Provider.
Note that when adding a lazy property to an existing class, you need to check if instances of the class are instantiated via ObjectFactory. Most classes are instantiated this way, but it's possible that a class without any lazy properties was never updated to use it. A tell-tale sign that an object is not instantiated via the ObjectFactory are direct calls to the constructor with new.
Existing properties in existing classes#
This is out of scope for this ADR. Migrating an existing property to lazy types is being handled in a different way.
Conventions#
Once a new property has been introduced, you need to consider what its conventions will be.
A convention is a value for a property that is used when no other opinion has been provided. Sometimes conventions are called "default values", but this can be confusing because no property has a "default value" upon creation (except for collection-like properties, which start empty). Conventions need to be explicitly set on a property.
When defining conventions for a property, there are largely three approaches: 1. Do not set a convention at all 2. Set a convention in a plugin 3. Set a convention in a constructor
Most properties should have a convention set, so (1) can be treated as a rare case where a value must be provided by a user.
Best practice is to set conventions in a plugin (2). This keeps the underlying object "dumb", so it can be reused in multiple contexts and doesn't contain any special information about how conventions are calculated or what they could be. In the wild, we've seen some objects set conventions in the object's constructor (3), but this can lead to unexpected assumptions or coupling between plugins.
For external plugins, it's difficult for an object to be used outside the application of a plugin, so following best practices is relatively straightforward. For core plugins in gradle/gradle, it's easy for an object to be created without applying its associated plugin. For instance, Spring has a plugin that uses the Checkstyle task directly without applying the Checkstyle plugin.
Until we can provide the same guarantees for core plugins, we need to be more conservative for existing classes and not follow best practices. This means conventions need to be set in both a plugin and the constructor. This keeps existing builds working, but it may complicate the implementation.
This is preferred for entirely new things:
public interface NewThing {
Property<String> getSomeProperty()
}
// in plugin
newThing = objects.newInstance(NewThing.class)
newThing.convention("some-value")
This is acceptable for existing things:
public interface ExistingThing {
Property<String> getSomeProperty()
}
abstract class DefaultExistingThing implements ExistingThing {
DefaultExistingThing() {
getSomeProperty().convention("some-value")
}
}
// in plugin
existingThing = objects.newInstance(DefaultExistingThing.class)
existingThing.convention("some-value")
It's not acceptable to treat an unset Provider as if the convention is requested when the convention could be set elsewhere:
public interface NewThing {
Property<String> getSomeProperty()
}
// Unacceptable
String value
if (!getSomeProperty().isPresent()) {
value = "convention"
} else {
value = getSomeProperty().get()
}
// Also unacceptable
String value = getSomeProperty().getOrElse("convention")
// This should be always:
String value = getSomeProperty().get()
Status#
ACCEPTED
Consequences#
- We are incurring debt that will need to be paid later via deprecations or breaking changes to existing classes. The implementation for some Gradle types will be more complicated than an equivalent clean-sheet implementation.
- During code reviews that introduce new APIs, reviewers need to be mindful that all new properties are implemented with lazy types.
- During code reviews, reviewers need to follow the recommendations here.