namalzi

← All posts

Digging into Gradle's Source 7: Applying Plugins — From the plugins {} Block to apply()

Gradle · Published

What does a single plugins { java } line turn into in the source? DefaultPluginRequestApplicator resolves every request first and only then applies them all; a resolver chain asks core plugins, buildSrc, included builds and the Plugin Portal in turn, using marker artifacts; META-INF/gradle-plugins descriptors turn an id into a class; DefaultPluginManager runs a plugin only once even if you apply it twice; withPlugin fires at a precise moment; extensions get attached; java pulls in java-base and base; and buildSrc and precompiled script plugins fit in — all checked with a plugin we write ourselves.

What this post covers

This is the seventh post in the "Digging into Gradle's Source" series. In post 6 we followed a build.gradle.kts file from compilation to execution, up to the point where both the Kotlin DSL and the Groovy DSL cut out the buildscript {} and plugins {} blocks and run them first, and only then run the body. This post covers what happens in between: how the requests collected by that early plugins {} run become real Plugin.apply() calls.

Going back to the map in post 2 (the "post 2 map" figure), this post zooms into the middle box of area "D, the configuration phase": plugin application (plugins {} → apply()). The box on its left (running the build script) belongs to post 6, and the box on its right (task registration and Providers) to post 8.

We want to answer four questions.

Item Value
Project Gradle Build Tool
Version 9.10.0 development build (master)
Commit 07ecbf9ead (2026-10-24)
Main classes plugin/use/internal/DefaultPluginRequestApplicator, PluginResolverFactory, plugin/use/resolve/internal/{AlreadyOnClasspath,Core,ArtifactRepositories}PluginResolver, api/internal/plugins/{DefaultPluginManager,DefaultPluginRegistry,DefaultPluginContainer}, internal/extensibility/DefaultExtensionContainer, JavaPlugin·JavaBasePlugin·BasePlugin

All line numbers in this post refer to this commit. Post 1 explains how to check out the same commit and build the Docker image used for the examples.

The plugin code is spread over platforms/extensibility/plugin-use (resolving requests), api/internal/plugins in subprojects/core (applying them), and dozens of modules for the individual plugins. This post only follows the main path of one plugins {} block. What was left out is listed by name and location in "What this post leaves out" at the end.

Terms to know first

A plugin is a class that implements the Plugin<T> interface. It has a single method, apply(T target), and T is usually Project (there are also plugins for Settings and Gradle). In the end, all a plugin does is add tasks, extensions and configuration to the project inside apply(). Think of it as a box that packages up things you could have written by hand in a build script, so they can be reused.

A plugin id is a dotted name such as org.gradle.java or org.jetbrains.kotlin.jvm. Everything before the last dot is the namespace. All plugins shipped with Gradle live in the org.gradle namespace and are called core plugins. Writing an id without a namespace, as in id("java"), means org.gradle.java (section 3).

A plugin request is one line of the plugins {} block. It becomes a PluginRequestInternal object holding the id, the version, whether it says apply false, and the line number it was written on. A request only says "I want this plugin"; no class has been looked up yet.

Resolution decides where to get the plugin for a request. The objects that do this are resolvers (PluginResolver), and several of them are lined up and asked one after another (section 2).

A marker artifact is an empty shell library in a repository such as the Plugin Portal that says "the plugin with id foo.bar lives in this library". Its coordinates are fixed as foo.bar:foo.bar.gradle.plugin:<version>, so knowing the id is enough to build them.

An extension is a configuration object a plugin attaches to the project. The java { … } and base { … } blocks in a build script configure extensions. They are attached by name to project.extensions (section 6).

A classloader scope (ClassLoaderScope) is a node in the classloader tree that runs settings → buildSrc → root project → subprojects. Plugin jars are loaded into one node of this tree. We saw the tree itself in post 6; here we only look at which node a plugin lands in (sections 1 and 8).

1. The big picture: resolve everything first, then apply

Let's recap post 6. On the Groovy DSL side, DefaultScriptPluginFactory compiles a script twice: first only plugins {} and buildscript {}, then the rest of the body. The star of this post sits between the two.

            // Pass 1, extract plugin requests and plugin repositories and execute buildscript {}, ignoring (i.e. not even compiling) anything else
            CompileOperation<?> initialOperation = compileOperationFactory.getPluginsBlockCompileOperation(initialPassScriptTarget);
            Class<? extends BasicScript> scriptType = initialPassScriptTarget.getScriptClass();
            ScriptRunner<? extends BasicScript, ?> initialRunner = compiler.compile(scriptType, target, baseScope, initialOperation, Actions.doNothing());
            initialRunner.run(target, services);

            PluginManagerInternal pluginManager = topLevelScript ? initialPassScriptTarget.getPluginManager() : null;
            PluginRequests initialPluginRequests = getInitialPluginRequests(initialRunner);
            PluginRequests allPluginRequests = pluginHandler.getAllPluginRequests(initialPluginRequests, target);
            pluginRequestApplicator.applyPlugins(allPluginRequests, scriptHandler, pluginManager, targetScope);

subprojects/core/src/main/java/org/gradle/configuration/DefaultScriptPluginFactory.java lines 114–123

The Kotlin DSL arrives at the same place. The Kotlin interpreter from post 6 runs the plugins {} block, collects the requests and calls applyPluginsTo() (KotlinScriptEvaluator.kt lines 262–269), which goes through PluginRequestsHandler to the same PluginRequestApplicator.applyPlugins() (PluginRequestsHandler.kt lines 43–50). You can see this exact path in the stack trace of the example in section 4.

On both paths, pluginHandler.getAllPluginRequests() steps in once. It adds plugins the user didn't write but that are applied automatically (for example the Develocity plugin that --scan adds to the settings script — for a project build script this list is empty). We won't follow it further here.

DefaultPluginRequestApplicator.applyPlugins(), which receives the request list, is the heart of this post. Here it is, with the cuts marked.

    @Override
    public void applyPlugins(PluginRequests requests, final ScriptHandlerInternal scriptHandler, @Nullable final PluginManagerInternal target, final ClassLoaderScope classLoaderScope) {
        // ... with no requests, only export and lock, then return ...

        PluginArtifactRepositories resolveContext = pluginRepositoriesProvider.createPluginResolveRepositories();
        resolveContext.applyRepositoriesTo(scriptHandler.getRepositories());

        List<ApplyAction> pluginApplyActions = new ArrayList<>();
        CollectingPluginRequestResolutionVisitor pluginDependencies = new CollectingPluginRequestResolutionVisitor();

        // Resolve the plugin requests
        PluginResolver pluginResolver = wrapInAlreadyInClasspathResolver(classLoaderScope, resolveContext);
        for (PluginRequestInternal originalRequest : requests) {
            PluginRequestInternal request = pluginResolutionStrategy.applyTo(originalRequest);
            PluginResolution resolved = resolvePluginRequest(pluginResolver, request);

            resolved.accept(pluginDependencies);

            if (request.isApply()) {
                pluginApplyActions.add(new ApplyAction(request, resolved));
            }
            // ... record the plugin version ...
        }

        // Configure the resolution
        pluginDependencies.getAdditionalDependencies().forEach(scriptHandler::addScriptClassPathDependency);
        // ... module replacement settings ...

        // Perform resolution & configure the classloader
        classLoaderScope.export(scriptHandler.getInstrumentedScriptClassPath());
        pluginDependencies.getAdditionalClassloaders().forEach(classLoaderScope::export);
        classLoaderScope.lock();

        // Apply the plugins
        pluginApplyActions.forEach(action -> action.apply(target));
    }

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/internal/DefaultPluginRequestApplicator.java lines 84–136

The four comments are the names of the four stages, and their order matters.

  1. Resolve — every request is resolved first. At this point Gradle only decides "where to get it" and "what to add to the script classpath". A request with apply false is resolved but not added to the apply list (pluginApplyActions).
  2. Configure the resolution — for plugins that come from a repository, the marker artifact is added as a dependency on the script classpath (the same place as buildscript { dependencies { classpath(…) } }).
  3. Perform resolution & configure the classloader — getInstrumentedScriptClassPath() actually resolves that classpath into jar files, which are exported into this script's classloader scope, and then the scope is lock()ed. After that, no more jars can be added to the scope.
  4. Apply — only now is ApplyAction.apply() called, in request order.

How a plugins block becomes apply(). The plugins block in build.gradle.kts (id lab.hello, java) is ① run on its own first, and PluginRequestCollector gathers the request list, PluginRequests. ② Each request is resolved through the resolver chain. ③ Dependencies are added to the script classpath, and the classloader scope is exported and then locked. ④ Requests are applied in order with ApplyAction.apply(), and DefaultPluginManager.apply() calls apply(project) on the plugin instance. ⑤ Then the script body runs, and the hello and java blocks can be used. The blue boxes are what this post reads. ① belongs to post 6, and so does running the body in ⑤.

Why "resolve everything, then apply"? The source comments don't state the reason, but two things can be read from the shape of the code. First, all requests' dependencies go into one script classpath that is resolved in one go, so if two plugins want different versions of the same library, dependency conflict resolution (post 12) settles it once. Second, applying (loading plugin classes and calling apply()) only happens after the classloader scope is lock()ed. The ordering itself guarantees that the classpath of that classloader doesn't change while plugin code is running.

Easy to get confused

2. Resolution: the resolver chain asks, front to back

wrapInAlreadyInClasspathResolver() from stage 1 builds the resolver chain. The core of the chain is built by PluginResolverFactory.

    private void addDefaultResolvers(PluginArtifactRepositories pluginResolveContext, List<PluginResolver> resolvers) {
        resolvers.add(new NoopPluginResolver(corePluginRegistry));
        resolvers.add(new CorePluginResolver(documentationRegistry, corePluginRegistry));

        injectedClasspathPluginResolver.collectResolversInto(resolvers);

        pluginResolverContributors.forEach(contributor -> contributor.collectResolversInto(resolvers));
        resolvers.add(pluginResolveContext.getPluginResolver());
    }

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/internal/PluginResolverFactory.java lines 83–91

The Javadoc just above it (lines 66–82) explains the order: NoopPluginResolver is only for tests, CorePluginResolver handles plugins shipped with Gradle, then comes the classpath of a TestKit test, then "resolvers contributed by this distribution — plugins coming from included builds", and finally the repositories. CompositePluginResolver walks this list with a for loop and returns the first answer that says "found". If nothing is found, it collects each resolver's "not found" reason (CompositePluginResolver.java lines 32–43). Unlike Tomcat valves or Spring interceptors, where each link decides whether to call the next, this is one supervisor asking a queue of resolvers in turn.

Then AlreadyOnClasspathPluginResolver wraps the whole chain once more (DefaultPluginRequestApplicator.java lines 138–143).

    @Override
    public PluginResolutionResult resolve(PluginRequestInternal pluginRequest) {
        PluginId pluginId = pluginRequest.getId();
        if (isCorePlugin(pluginId) || !isPresentOnClasspath(pluginId)) {
            return delegate.resolve(pluginRequest);
        }

        String version = getVersion(pluginRequest);
        if (version == null) {
            return resolveAlreadyOnClasspath(pluginId, null);
        }
        // ... if a version was given, check it matches the one already loaded ...

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/resolve/internal/AlreadyOnClasspathPluginResolver.java lines 60–70

The classpath in "already on the classpath" is that of the parent classloader scope (the export classloader of classLoaderScope.getParent(), lines 139–140). For the root project's script the parent is buildSrc's scope, so plugins built in buildSrc are found here (section 8). For a subproject the parent is the root project's scope, so plugins already fetched by the root's plugins {} are reused instead of being fetched again.

The plugin resolver chain. One plugin request (id, version) goes first to AlreadyOnClasspathPluginResolver. If it is already on the parent scope's classpath (lab.hello from buildSrc), it is found right away. If it is absent or a core plugin, Gradle asks NoopPluginResolver (tests only) → CorePluginResolver (core plugins in the org.gradle namespace, java) → the TestKit classpath resolver → Included Builds (CompositeBuildPluginResolverContributor) → ArtifactRepositoriesPluginResolver (marker artifact id:id.gradle.plugin, pluginManagement repositories, otherwise the Plugin Portal). If CorePluginResolver or the repositories find it, it becomes a PluginResolution; if nothing does, the reasons from every source are combined into a "was not found in any of the following sources" error. The first resolver to say "found" wins. That also means resolvers earlier in the chain mask the later ones.

Core plugins: filtered by namespace alone

    @Override
    public PluginResolutionResult resolve(PluginRequestInternal pluginRequest) {
        PluginId id = pluginRequest.getId();
        if (!isCorePluginRequest(id)) {
            return PluginResolutionResult.notFound(getDescription(), format("plugin is not in '%s' namespace", CORE_PLUGIN_NAMESPACE));
        }

        PluginImplementation<?> plugin = pluginRegistry.lookup(id);
        if (plugin == null) {
            return PluginResolutionResult.notFound(getDescription(), "not a core plugin. " + documentationRegistry.getDocumentationRecommendationFor("available plugins", "plugin_reference"));
        }

        validate(pluginRequest);
        return PluginResolutionResult.found(new SimplePluginResolution(plugin));
    }

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/resolve/internal/CorePluginResolver.java lines 39–53

If there's no namespace or it is org.gradle (lines 80–83), it looks in Gradle's own plugin registry. Once found, validate() rejects three things (lines 55–74): core plugins can't have a version, can't name a different implementation artifact, and can't use apply false. Core plugins ship with Gradle and are already on the classpath, so none of the three makes sense.

Repositories: found through marker artifacts

ArtifactRepositoriesPluginResolver, at the end of the chain, builds marker artifact coordinates from the id.

    private ModuleDependency getMarkerDependency(PluginRequestInternal pluginRequest) {
        ComponentSelector selector = pluginRequest.getSelector();
        if (!(selector instanceof ModuleComponentSelector)) {
            String id = pluginRequest.getId().getId();
            return getDependencyFactory().create(id, id + PLUGIN_MARKER_SUFFIX, pluginRequest.getVersion());
        } else {
            // ... when pluginManagement's resolutionStrategy names the module directly ...
        }
    }

platforms/extensibility/plugin-use/src/main/java/org/gradle/plugin/use/resolve/internal/ArtifactRepositoriesPluginResolver.java lines 189–198

PLUGIN_MARKER_SUFFIX is ".gradle.plugin" (line 45). For the id org.jetbrains.kotlin.jvm the coordinates are org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:<version>. Without a version this resolver backs off immediately, saying a version is required (lines 59–62). With a version, it does one resolution to check that the marker really exists in the repositories (exists(), lines 179–187 — the TODO comment in the source admits that "performing resolution here is likely quite inefficient"), and if so returns an ExternalPluginResolution. In stage 2 of section 1, that result adds the marker as a script classpath dependency (lines 122–132). The marker's POM pulls in the real plugin jar as a dependency, so once stage 3's resolution finishes, the real jar is on the classloader.

Which repositories? The constructor of DefaultPluginArtifactRepositories decides (lines 42–49). It wraps each shared repository and adds it, and if the list is still empty afterwards it calls repositoryHandler.gradlePluginPortal(). The shared repositories are the ones written in pluginManagement { repositories { … } } in settings.gradle.kts (DefaultPluginManagementSpec.getRepositories() returns the same list, lines 60–63). If you write none, the Gradle Plugin Portal alone is used.

The first line of this blog's own build.gradle.kts is kotlin("jvm") version "2.3.21". kotlin(…) is a function the Kotlin DSL provides that calls id("org.jetbrains.kotlin.$module") (GenerateKotlinDependencyExtensions.kt lines 120–121). So that line is not in the org.gradle namespace and passes CorePluginResolver by; the blog's settings.gradle.kts has no pluginManagement, so it goes looking for the marker org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:2.3.21 on the Plugin Portal. (This was worked out by following the source, not confirmed in the blog build's logs. The blog uses Gradle 9.5.1 through its wrapper, so it's a different version from our reference source, too.)

Example 1: the chain you see when resolution fails

The easiest way to see the chain is to make it fail on purpose. I made two projects.

// Example written for this post (not part of Gradle's source)
plugins {
    id("java") version "9.10.0"   // what if a core plugin gets a version?
}

[Example ex/u07-resolve-errors/core-with-version/build.gradle.kts]

// Example written for this post (not part of Gradle's source)
plugins {
    id("lab.missing") version "1.0"   // a plugin that exists nowhere
}

[Example ex/u07-resolve-errors/missing-plugin/build.gradle.kts]

This example ran in a Docker container (Eclipse Temurin JDK 25) with the Gradle 9.10.0 development build compiled straight from the reference commit (07ecbf9ead). run.sh runs gradle help --offline --console=plain in both projects and prints only the failure message.

=== core-with-version ===
* What went wrong:
Error resolving plugin [id: 'java', version: '9.10.0']
> Plugin 'java' is a core Gradle plugin, which cannot be specified with a version number. Such plugins are versioned as part of Gradle. Please remove the version number from the declaration.

* Try:
=== missing-plugin ===
* What went wrong:
Plugin [id: 'lab.missing', version: '1.0'] was not found in any of the following sources:

- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Included Builds (No included builds contain this plugin)
- Plugin Repositories (could not resolve plugin artifact 'lab.missing:lab.missing.gradle.plugin:1.0')
  Searched in the following repositories:
    Gradle Central Plugin Repository

* Try:

Easy to get confused

3. From id to class: META-INF/gradle-plugins

When a resolver says "found", what it actually did was turn an id into a class. The rule is surprisingly simple: look for one file on the classpath.

    @Override
    public PluginDescriptor findPluginDescriptor(String pluginId) {
        URL resource = classLoader.getResource("META-INF/gradle-plugins/" + pluginId + ".properties");
        if (resource == null) {
            return null;
        } else {
            return new PluginDescriptor(resource);
        }
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/ClassloaderBackedPluginDescriptorLocator.java lines 29–37

This .properties file is called the plugin descriptor. Only one line in it is read, implementation-class (PluginDescriptor.java lines 32–35). The descriptor of the java plugin looks like this.

implementation-class=org.gradle.api.plugins.JavaPlugin

platforms/jvm/plugins-java/src/main/resources/META-INF/gradle-plugins/org.gradle.java.properties

DefaultPluginRegistry.lookup() uses this file to find the class. It's also where an id without a namespace gets org.gradle filled in.

    @Nullable
    private PluginImplementation<?> lookup(PluginId pluginId, ClassLoader classLoader) {
        // Don't go up the parent chain.
        // Don't want to risk classes crossing "scope" boundaries and being non collectible.

        if (pluginId.getNamespace() == null) {
            PluginId qualified = pluginId.withNamespace(DefaultPluginManager.CORE_PLUGIN_NAMESPACE);
            Optional<PluginImplementation<?>> lookup = uncheckedGet(idMappings, new PluginIdLookupCacheKey(qualified, classLoader));
            if (lookup.isPresent()) {
                return lookup.get();
            }
        }

        return uncheckedGet(idMappings, new PluginIdLookupCacheKey(pluginId, classLoader)).orElse(null);
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginRegistry.java lines 170–184

idMappings is a Guava LoadingCache. The first time an (id, classloader) pair is asked for, the loader on lines 55–86 runs: it finds the descriptor, loadClass()es the implementation-class, inspects it with PluginInspector.inspect() ("does it implement Plugin, or is it a rule source?"), and wraps the result as a PluginImplementation. After that it comes from the cache.

How a plugin id becomes a class. id("java"), or java in Kotlin, is id("org.gradle.java"). DefaultPluginRegistry.lookup() first tries with org.gradle. prepended when there is no namespace. ClassloaderBackedPluginDescriptorLocator looks up getResource("META-INF/gradle-plugins/org.gradle.java.properties"). If found, it reads implementation-class=org.gradle.api.plugins.JavaPlugin from the PluginDescriptor, and after classLoader.loadClass() and PluginInspector.inspect() it becomes a PluginImplementation, the class bundled with its id. If there is no descriptor, it returns null and the next resolver takes over. The yellow boxes are files on the classpath. There is no central id → class table anywhere; it is scattered across jars as descriptor files.

Why do it this way? With a central table, every new plugin would require editing that table. With the descriptor inside the jar, putting the jar on the classpath is enough to make the id exist. It's the same idea as Java's ServiceLoader (META-INF/services/…), and Gradle itself uses the ServiceLoader style too (the auto-applied plugins in section 7).

Who writes the descriptor for your own plugin? The pluginDescriptors task of the java-gradle-plugin plugin. For each id in gradlePlugin { plugins { create(…) { id = …; implementationClass = … } } }, it creates <id>.properties with a single implementation-class line (GeneratePluginDescriptors.java lines 95–102). Opening the buildSrc jar from the section 4 example shows the result (the end of ex/u07-plugin-apply/run.sh).

=== plugin descriptor inside the buildSrc jar ===
lab/
lab/HelloExtension.class
lab/HelloPlugin.class
META-INF/gradle-plugins/
META-INF/gradle-plugins/lab.hello.properties
implementation-class=lab.HelloPlugin

What is java in the Kotlin DSL?

The java in plugins { java } is neither a Kotlin keyword nor magic. It is an extension property on PluginDependenciesSpec. While Gradle itself is being built, a code generator in build-logic scans Gradle's jars and creates one property per descriptor, named after the id with org.gradle. removed (PluginIdExtensionsFacade.kt lines 106–113; the code template is on lines 67–75). The generated code ships as source inside the distribution's lib/gradle-kotlin-dsl-extensions-9.10.0.jar, and the same example's run.sh pulls out the relevant part.

=== Kotlin DSL accessor for 'java' inside the distribution ===
inline val org.gradle.plugin.use.PluginDependenciesSpec.`java`: org.gradle.plugin.use.PluginDependencySpec
    get() = id("org.gradle.java")

So plugins { java } is exactly plugins { id("org.gradle.java") }. `java-library` and application are generated the same way.

4. Applying: how DefaultPluginManager gets to apply()

When resolution is done and ApplyAction.apply() from stage 4 of section 1 runs, it calls applyTo(pluginManager) on each resolution result (PluginResolution). For both core plugins (SimplePluginResolution) and buildSrc plugins (ClassPathPluginResolution) it's one line — pluginManager.apply(plugin) (ClassPathPluginResolution.java lines 47–50). One that came from a repository (ExternalPluginResolution) calls pluginManager.apply(id) with the id string (lines 149–156); by now the classloader is finalized, so the id can be looked up.

project.pluginManager is really a DefaultPluginManager, one per project, created as a project-scope service (ProjectScopeServices.java lines 215–249 — service scopes were in post 4). apply(plugin = "…") in a build script body, project.getPluginManager().apply(…) inside a plugin, and the plugins {} block all end up in this object's apply(). There are three apply methods (apply(String), apply(Class), apply(PluginImplementation), lines 144–161), and they — along with addImperativePlugin() (lines 95–105), which project.plugins.apply(…) (PluginContainer) uses — all call doApply().

    private void doApply(final PluginImplementation<?> plugin) {
        PluginId pluginId = plugin.getPluginId();
        final String pluginIdStr = pluginId == null ? null : pluginId.toString();
        final Class<?> pluginClass = plugin.asClass();
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        try {
            Thread.currentThread().setContextClassLoader(pluginClass.getClassLoader());
            if (plugin.getType().equals(PotentialPlugin.Type.UNKNOWN)) {
                throw new InvalidPluginException("'" + pluginClass.getName() + "' is neither a plugin or a rule source and cannot be applied.");
            } else {
                final Runnable adder = addPluginInternal(plugin);
                if (adder != null) {
                    UserCodeApplicationContext.Target appTarget = getApplicationTargetFor(target);
                    UserCodeSource source = new UserCodeSource.Binary(plugin.getDisplayName(), pluginClass.getName(), pluginIdStr);
                    userCodeApplicationContext.apply(source, appTarget, userCodeApplicationId ->
                        buildOperationRunner.run(new AddPluginBuildOperation(adder, plugin, pluginIdStr, pluginClass, userCodeApplicationId))
                    );
                }
            }
        } // ... two catch blocks (wrap in PluginApplicationException), finally (restore the context classloader) ...
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginManager.java lines 163–189

Four things to notice.

The guard against applying twice is short.

    @Nullable // if the plugin has already been added
    private Runnable addPluginInternal(final PluginImplementation<?> plugin) {
        final Class<?> pluginClass = plugin.asClass();
        if (plugins.containsKey(pluginClass)) {
            return null;
        }

        plugins.put(pluginClass, plugin);
        return new Runnable() {
            @Override
            public void run() {
                appliedPlugins.add(plugin);
                // ... add this plugin to the id sets that withPlugin is waiting on (section 5) ...
            }
        };
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginManager.java lines 107–128

The key is the class, not the id. apply(plugin = "java"), apply(plugin = "org.gradle.java") and apply<JavaPlugin>() are all the same JavaPlugin.class, so it is applied only once. Also, the plugin is put into the plugins map when application starts, while the returned Runnable (the adder) runs after application finishes. That gap is the story of section 5.

addPlugin(), called inside the build operation, does the real work.

    private void addPlugin(Runnable adder, PluginImplementation<?> plugin, String pluginId, Class<?> pluginClass) {
        boolean imperative = plugin.isImperative();
        if (imperative) {
            Plugin<?> pluginInstance = producePluginInstance(pluginClass);

            if (plugin.isHasRules()) {
                target.applyImperativeRulesHybrid(pluginId, pluginInstance, pluginClass);
            } else {
                target.applyImperative(pluginId, pluginInstance);
            }

            // Important not to add until after it has been applied as there can be
            // plugins.withType() callbacks waiting to build on what the plugin did
            instances.put(pluginClass, pluginInstance);
            pluginContainer.pluginAdded(pluginInstance);
        } else {
            target.applyRules(pluginId, pluginClass);
        }

        adder.run();
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginManager.java lines 204–224

"Imperative" means an ordinary plugin that implements Plugin. The "rules" side is for rule-based plugins from the old software model, which we skip here.

① Creating the instance. producePluginInstance() ends up calling PluginInstantiator.newInstance().

    @Override
    public <T> T newInstance(Class<? extends T> type, @Nullable Object... parameters) throws ObjectInstantiationException {
        // If the plugin type is abstract, it likely has abstract methods that should be implemented
        // during class generation. In this case, we should use the decorated instantiator to create
        // the plugin instance.  Otherwise, we should use the injected instantiator to create the plugin
        // since some existing plugin types are known to be final.
        if (Modifier.isAbstract(type.getModifiers())) {
            return decoratedInstantiator.newInstance(type, parameters);
        } else {
            return injectedInstantiator.newInstance(type, parameters);
        }
    }

subprojects/core/src/main/java/org/gradle/api/internal/plugins/PluginInstantiator.java lines 41–52

If the plugin class is abstract, the class generator from post 4 creates a …_Decorated subclass that fills in abstract methods such as @Inject getters. If not, an instantiator that only does constructor injection is used. In both cases the constructor's @Inject parameters are filled from project-scope services (withServices(projectScopeServiceRegistry) on lines 240–241 of ProjectScopeServices). That's how JavaBasePlugin's constructor receives JvmPluginServices and PropertyFactory (JavaBasePlugin.java lines 153–158).

② Calling apply(). For a project, target is an ImperativeOnlyPluginTarget wrapped by a RuleBasedPluginTarget (ProjectScopeServices lines 230–235). All its applyImperative() does is cast the plugin to Plugin<T> and call cast.apply(target) (ImperativeOnlyPluginTarget.java lines 48–58). At last, the apply(Project) we wrote. If you apply a Plugin<Settings> in a build script, a ClassCastException happens here, and maybeThrowOnTargetMismatch() reads the generic type argument and turns it into a friendlier sentence such as "The plugin must be applied in a settings script (or to the Settings object), but was applied in a build script (or to the Project object)" (I filled this sentence into the source's template on line 71 and did not run it. Lines 60–78; the phrases come from PluginTargetType.java lines 28–30).

③ Notifying. When apply() returns, pluginContainer.pluginAdded() puts the instance into the project.plugins collection (this is when plugins.withType(…) callbacks fire), and finally adder.run() runs (this is when withPlugin(…) callbacks fire). As the comment says, "important not to add until after it has been applied" — callbacks want to build on top of what the plugin has done.

The call order for applying one plugin. ① RequestApplicator calls DefaultPluginManager.apply(plugin). ② addPluginInternal() stops if it is already present. ③ The BuildOperationRunner opens an "Apply plugin lab.hello" operation and addPlugin() runs inside it. ④ PluginInstantiator.newInstance(HelloPlugin) returns HelloPlugin_Decorated. ⑤ PluginTarget.applyImperative() ⑥ calls HelloPlugin.apply(project), and the plugin registers an extension, a task and callbacks and returns. ⑦ After pluginAdded(), adder.run() runs, firing withType callbacks and then withPlugin callbacks. The numbers follow the stack trace in Example 2, read from the bottom up.

Example 2: the call path inside a buildSrc plugin's apply()

I wrote a Java plugin in buildSrc with java-gradle-plugin. Here is buildSrc's build script.

// Example written for this post (not part of Gradle's source)
plugins {
    `java-gradle-plugin`
}

// java-gradle-plugin turns this declaration into META-INF/gradle-plugins/lab.hello.properties
gradlePlugin {
    plugins {
        create("hello") {
            id = "lab.hello"
            implementationClass = "lab.HelloPlugin"
        }
    }
}

[Example ex/u07-plugin-apply/project/buildSrc/build.gradle.kts]

HelloExtension.java is an abstract class with a single public abstract Property<String> getMessage(); (Gradle provides the implementation; section 6). Here is the plugin itself.

// Example written for this post (not part of Gradle's source)
package lab;

import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.plugins.JavaBasePlugin;

public abstract class HelloPlugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        System.out.println("[apply] HelloPlugin.apply(" + project.getPath() + "), class = " + getClass().getName());
        System.out.println("[apply] hasPlugin(\"lab.hello\") = " + project.getPluginManager().hasPlugin("lab.hello"));
        printCallers();

        // 1) Extension: the build script configures it with hello { message = "..." }
        HelloExtension hello = project.getExtensions().create("hello", HelloExtension.class);
        hello.getMessage().convention("Hello from lab.hello");

        // 2) Task: only registered here (post 8)
        project.getTasks().register("hello", task -> {
            task.setGroup("lab");
            task.doLast(t -> System.out.println("[task] " + hello.getMessage().get()));
        });

        // 3) Reacting to other plugins: java isn't here yet, but this fires when it is applied later
        project.getPluginManager().withPlugin("java-base", p ->
            System.out.println("[withPlugin java-base] fired, hasPlugin(\"java\") = "
                + project.getPluginManager().hasPlugin("java")));
        project.getPlugins().withType(JavaBasePlugin.class, p ->
            System.out.println("[withType JavaBasePlugin] fired"));
        project.getPluginManager().withPlugin("java", p ->
            System.out.println("[withPlugin java] fired, id = " + p.getId()));

        System.out.println("[apply] HelloPlugin.apply() done");
    }

    // ... printCallers(): prints only the stack frames related to plugins and scripts ...
}

[Example ex/u07-plugin-apply/project/buildSrc/src/main/java/lab/HelloPlugin.java, with only the body of printCallers() cut]

// Example written for this post (not part of Gradle's source)
plugins {
    id("lab.hello")   // the plugin built in buildSrc
    java              // = id("org.gradle.java")
}

println("[script] body starts")

// Configure the extension the plugin created
hello {
    message = "Hi from build.gradle.kts"
}
println("[script] hello extension class = " + hello.javaClass.name)

// Apply the same plugin once more -> nothing happens
apply(plugin = "lab.hello")

println("[script] plugins applied to this project:")
plugins.forEach { println("    " + it.javaClass.name) }
println("[script] extensions = " + extensions.extensionsSchema.elements.map { it.name })

[Example ex/u07-plugin-apply/project/build.gradle.kts]

run.sh runs gradle hello --offline --console=plain. Here is the first part of the output (the leading === gradle hello === separator and the Welcome to Gradle … banner are left out, and the stack trace is shortened by removing the printCallers() frame itself and two compiler-generated bridge-method frames, with the middle cut to ...).

Starting a Gradle Daemon (subsequent builds will be faster)
Shared read-only dependency cache is an incubating feature.
> Task :buildSrc:compileJava
> Task :buildSrc:compileGroovy NO-SOURCE
> Task :buildSrc:pluginDescriptors
> Task :buildSrc:processResources
> Task :buildSrc:classes
> Task :buildSrc:jar

> Configure project :
[apply] HelloPlugin.apply(:), class = lab.HelloPlugin_Decorated
[apply] hasPlugin("lab.hello") = true
[apply] called through:
    at lab.HelloPlugin.apply:14
    at org.gradle.api.internal.plugins.ImperativeOnlyPluginTarget.applyImperative:53
    at org.gradle.api.internal.plugins.RuleBasedPluginTarget.applyImperative:52
    at org.gradle.api.internal.plugins.DefaultPluginManager.addPlugin:212
    at org.gradle.api.internal.plugins.DefaultPluginManager.access$300:56
    at org.gradle.api.internal.plugins.DefaultPluginManager$AddPluginBuildOperation.run:309
    at org.gradle.api.internal.plugins.DefaultPluginManager.lambda$doApply$0:178
    at org.gradle.api.internal.plugins.DefaultPluginManager.doApply:177
    at org.gradle.api.internal.plugins.DefaultPluginManager.apply:146
    at org.gradle.plugin.use.resolve.internal.ClassPathPluginResolution.applyTo:49
    at org.gradle.plugin.use.internal.DefaultPluginRequestApplicator$ApplyAction.apply:161
    at org.gradle.plugin.use.internal.DefaultPluginRequestApplicator.lambda$applyPlugins$1:135
    at org.gradle.plugin.use.internal.DefaultPluginRequestApplicator.applyPlugins:135
    at org.gradle.kotlin.dsl.provider.PluginRequestsHandler.handle:45
    at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost.applyPluginsTo:263
    at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator.evaluate:141
    ...
    at org.gradle.configuration.project.BuildScriptProcessor.execute:46
    at org.gradle.configuration.project.ConfigureActionsProjectEvaluator.evaluate:35
    at org.gradle.configuration.project.LifecycleProjectEvaluator$EvaluateProject.lambda$run$0:109
    at org.gradle.configuration.project.LifecycleProjectEvaluator$EvaluateProject.run:100
    at org.gradle.configuration.project.LifecycleProjectEvaluator.evaluate:72
    at org.gradle.execution.TaskPathProjectEvaluator.configureHierarchy:77
[apply] HelloPlugin.apply() done

The Shared read-only dependency cache is an incubating feature. line appears because the example image uses pre-downloaded libraries as a read-only cache (GRADLE_RO_DEP_CACHE). It has nothing to do with the build result.

Easy to get confused

5. withPlugin: reacting without relying on order

HelloPlugin in Example 2 was applied before java (it came first in plugins {}). At that point there was no java, so if (hasPlugin("java")) would have been false. The user decides the order of plugins, so plugins must not rely on it. That's why withPlugin(id, action) exists: "call it now if it's there, otherwise when it gets applied later". The implementation just wraps the action and hands it to pluginsForId(id).all(…) (DefaultPluginManager.java lines 280–289).

pluginsForId(id) is a DomainObjectSet created once per id, and all(action) is the domain object collection method that applies an action to "everything in it now and everything added later" (the same principle as configureEach — post 8). What goes into that set, and when, is the crux.

    // Plugins whose application has started, including those still being applied
    private final Map<Class<?>, PluginImplementation<?>> plugins = new LinkedHashMap<>();
    // Plugins whose application has completed; withPlugin() callbacks only see these
    private final List<PluginImplementation<?>> appliedPlugins = new ArrayList<>();

subprojects/core/src/main/java/org/gradle/api/internal/plugins/DefaultPluginManager.java lines 65–68

There are two lists. plugins holds those whose application has started; appliedPlugins holds those that have finished. When pluginsForId() first creates a set it only scans appliedPlugins (lines 254–258), and after that the adder from section 4 fills it each time an application finishes. hasPlugin() (findPlugin(), lines 264–278), on the other hand, looks at plugins. In summary:

What you ask List it looks at For a plugin still being applied
pluginManager.hasPlugin(id) plugins (started) true
pluginManager.withPlugin(id) { } appliedPlugins → adder fires after it finishes
project.plugins.withType(T) { } pluginContainer fires at pluginAdded() (after it finishes, just before the adder)
project.plugins.hasPlugin(id) pluginContainer (and pluginsForId) false

The last row is a difference stated directly in the Javadoc of PluginManager.hasPlugin() ("In contrast, withPlugin and PluginContainer#hasPlugin only consider a plugin once its application has completed", PluginManager.java lines 82–84).

Let's see when the three callbacks registered by HelloPlugin in Example 2 fired. These are the lines right after the output above.

[apply] HelloPlugin.apply() done
[withType JavaBasePlugin] fired
[withPlugin java-base] fired, hasPlugin("java") = true
[withPlugin java] fired, id = java
[script] body starts

Here is what happened while java was being applied. The first thing JavaPlugin.apply() does is apply JavaBasePlugin (section 7).

The order of callbacks while java is applied. ① The plugins block calls DefaultPluginManager.apply(java); the manager puts JavaPlugin into the plugins map and calls JavaPlugin.apply(project). ② JavaPlugin calls apply(JavaBasePlugin.class), and the manager calls JavaBasePlugin.apply(project). When JavaBasePlugin finishes, ③ the withType JavaBasePlugin callback and ④ the withPlugin java-base callback of lab.hello fire. At that moment hasPlugin java is already true. The manager returns to JavaPlugin, and when ⑤ JavaPlugin.apply finishes, ⑥ the withPlugin java callback fires. withPlugin("java-base") fires while java is still being applied. At that moment hasPlugin("java") is true, but withPlugin("java") hasn't fired yet.

Easy to get confused

6. Extensions: where does the hello { } block come from?

HelloPlugin attached an extension with project.getExtensions().create("hello", HelloExtension.class), and the body configured it with hello { message = "…" }. DefaultExtensionContainer.create() simply creates the instance (instantiate()) and stores it by name together with its public type (DefaultExtensionContainer.java lines 82–87).

instantiate() uses the same class generator as plugins (instanceGenerator on lines 174–175). That's why HelloExtension's abstract method getMessage() gets filled in to return a Property<String>. The example output shows it.

[script] hello extension class = lab.HelloExtension_Decorated

Everything is stored in a single LinkedHashMap in ExtensionsStorage.

    private final Map<String, ExtensionHolder<?>> extensions = new LinkedHashMap<>();

    public <T> void add(TypeOf<T> publicType, String name, T extension) {
        if (hasExtension(name)) {
            throw new IllegalArgumentException(
                format("Cannot add extension with name '%s', as there is an extension already registered with that name.", name));
        }
        extensions.put(name, new ExtensionHolder<>(name, publicType, extension));
    }

platforms/core-configuration/model-core/src/main/java/org/gradle/internal/extensibility/ExtensionsStorage.java lines 35–43

The name is the key, and the public type is recorded along with the value. Looking up by type, as in getByType(JavaPluginExtension.class), first tries an exact public type match and then an assignable one (lines 93–118). JavaBasePlugin passes a separate public type in create(JavaPluginExtension.class, "java", DefaultJavaPluginExtension.class, …) so that users only see the interface (JavaBasePlugin.java lines 183–187).

These are the extensions attached to the example project.

[script] extensions = [ext, versionCatalogs, hello, base, sourceSets, reporting, javaToolchains, java, testing]

The first, ext, is the "extra properties" extension that DefaultExtensionContainer's constructor attaches itself (lines 49–52). Everything after hello came from applying java; the figure in section 7 shows who attached which. Since the map is a LinkedHashMap, the order is the order they were added.

How the hello extension gets attached and used. When HelloPlugin.apply() calls project.getExtensions().create("hello", HelloExtension.class), DefaultExtensionContainer.create() uses the class generator in instantiate() to make a HelloExtension_Decorated instance, takes HelloExtension (with _Decorated stripped) as the public type, and passes them on with add(public type, name, value). If the name already exists, it throws. ExtensionsStorage is a LinkedHashMap keyed by name, so the order of addition is preserved. The example project's actual list is ext (added by the constructor itself, public type ExtraPropertiesExtension), versionCatalogs, hello (public type HelloExtension, value HelloExtension_Decorated), and then base, sourceSets, reporting, javaToolchains, java (public type JavaPluginExtension) and testing, added by applying java. The Kotlin DSL makes a hello accessor from this extension list after plugins {} is applied and compiles the body with it, which is why the body can use hello { message = … }. An extension goes into the map with a name and a public type. The list is in exactly the order of the example's actual output.

The reason you can just write hello { } in the body is that the Kotlin DSL looks at the extension list (extensionsSchema) after the plugins {} block has been applied, generates a type-safe accessor called hello, and compiles the body with it. Accessor generation was covered in post 6. If you had applied the plugin only with apply(plugin = "lab.hello") in the body, hello wouldn't exist at compile time, and you'd have to write the<HelloExtension>() or configure<HelloExtension> { } — one big reason to prefer the plugins {} block.

Easy to get confused

7. A real case: how one java line becomes nine plugins

This is the plugins list from Example 2.

[script] plugins applied to this project:
    org.gradle.api.plugins.HelpTasksPlugin_Decorated
    org.gradle.api.plugins.SoftwareReportingTasksPlugin_Decorated
    org.gradle.buildinit.plugins.BuildInitPlugin_Decorated
    org.gradle.buildinit.plugins.WrapperPlugin_Decorated
    lab.HelloPlugin_Decorated
    org.gradle.language.base.plugins.LifecycleBasePlugin_Decorated
    org.gradle.api.plugins.BasePlugin_Decorated
    org.gradle.api.plugins.JvmEcosystemPlugin_Decorated
    org.gradle.api.plugins.ReportingBasePlugin_Decorated
    org.gradle.api.plugins.JvmToolchainsPlugin_Decorated
    org.gradle.api.plugins.JavaBasePlugin_Decorated
    org.gradle.testing.base.plugins.TestSuiteBasePlugin_Decorated
    org.gradle.api.plugins.JvmTestSuitePlugin_Decorated
    org.gradle.api.plugins.JavaPlugin_Decorated
    org.gradle.kotlin.dsl.provider.plugins.KotlinScriptBasePlugin_Decorated

I applied two, lab.hello and java, yet there are fifteen. They fall into three groups.

The top four were applied although nobody asked for them. That's why tasks like gradle tasks, gradle help, gradle dependencies, gradle init and gradle wrapper exist in every project. They are applied by PluginsProjectConfigureActions, which runs before the build script (BuildScriptProcessor) in post 6's evaluator chain (BuildModelControllerServices.kt lines 119–123). It gathers the actions registered in META-INF/services/org.gradle.configuration.project.ProjectConfigureAction files through a ServiceLocator and calls them in turn (PluginsProjectConfigureActions.java lines 27–49); one of them, HelpTasksAutoApplyAction, is the single line project.getPluginManager().apply("org.gradle.help-tasks") (HelpTasksAutoApplyAction.java lines 22–27). It's the same "register with a file inside the jar" approach as the descriptors in section 3.

The bottom one, KotlinScriptBasePlugin, is applied by the Kotlin DSL. applyBasePluginsTo() calls it after the plugins {} block has been applied (DefaultKotlinScriptBasePluginsApplicator.kt lines 26–28).

The nine in the middle were all applied by java alone. Here is the start of JavaPlugin.apply().

    @Override
    public void apply(final Project project) {
        if (project.getPluginManager().hasPlugin("java-platform")) {
            throw new IllegalStateException("The \"java\" or \"java-library\" plugin cannot be applied together with the \"java-platform\" plugin. " +
                "A project is either a platform or a library but cannot be both at the same time.");
        }
        final ProjectInternal projectInternal = (ProjectInternal) project;

        project.getPluginManager().apply(JavaBasePlugin.class);
        project.getPluginManager().apply("org.gradle.jvm-test-suite"); // TODO: change to reference plugin class by name after project dependency cycles untangled; this will affect ApplyPluginBuildOperationIntegrationTest (will have to remove id)

        SourceSetContainer sourceSets = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets();
        // ... the java component and main feature (main source set, jar task), default test suite ...
    }

platforms/jvm/plugins-java/src/main/java/org/gradle/api/plugins/JavaPlugin.java lines 264–298

A plugin applies another plugin exactly the way a user does: pluginManager.apply(). So the machinery from section 4 (the twice-guard, the build operation, the callbacks) applies to the inner plugins as well. JavaBasePlugin.apply() has the same shape.

    @Override
    public void apply(final Project project) {
        project.getPluginManager().apply(BasePlugin.class);
        project.getPluginManager().apply(JvmEcosystemPlugin.class);
        project.getPluginManager().apply(ReportingBasePlugin.class);
        project.getPluginManager().apply(JvmToolchainsPlugin.class);

        DefaultJavaPluginExtension javaPluginExtension = addExtensions(project);

        configureCompileDefaults(project, javaPluginExtension);
        configureSourceSetDefaults(project, javaPluginExtension);
        // ... defaults for javadoc, test, buildNeeded/buildDependents, archives, JavaExec ...
    }

platforms/jvm/plugins-java-base/src/main/java/org/gradle/api/plugins/JavaBasePlugin.java lines 163–181

BasePlugin in turn applies LifecycleBasePlugin and attaches the base extension (BasePlugin.java lines 62–70). LifecycleBasePlugin registers clean, assemble, check and build (LifecycleBasePlugin.java lines 73–80). This is also where build is made to depend on assemble and check (lines 115–121).

The plugins that the java plugin applies. JavaPlugin (java; creates the main source set, default test suite, jar) calls ① apply(JavaBasePlugin.class) and ② apply(org.gradle.jvm-test-suite). JavaBasePlugin (java-base; java extension, registers compileJava per source set) applies BasePlugin (base; base extension, archive defaults), JvmEcosystemPlugin (sourceSets extension), ReportingBasePlugin (reporting extension) and JvmToolchainsPlugin (javaToolchains extension). BasePlugin applies LifecycleBasePlugin (clean, assemble, check, build). JvmTestSuitePlugin (jvm-test-suite) adds the testing extension via TestSuiteBasePlugin. Blue boxes are plugins quoted in this section; grey boxes only give the name and what it does. The extension list in section 6 comes from here.

Running the same example with the build operation trace (-Dorg.gradle.internal.operations.trace=/tmp/trace, post 1) and extracting only the names of the Apply plugin operations shows the order in which they started (run.sh picks the root project's entries out of /tmp/trace-log.txt).

=== build operation trace: Apply plugin (root project) ===
"Apply plugin org.gradle.help-tasks to root project 'plugin-apply'"
"Apply plugin org.gradle.software-reporting-tasks to root project 'plugin-apply'"
"Apply plugin org.gradle.build-init to root project 'plugin-apply'"
"Apply plugin org.gradle.wrapper to root project 'plugin-apply'"
"Apply plugin lab.hello to root project 'plugin-apply'"
"Apply plugin org.gradle.java to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.JavaBasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.BasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.language.base.plugins.LifecycleBasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.JvmEcosystemPlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.ReportingBasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.api.plugins.JvmToolchainsPlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.jvm-test-suite to root project 'plugin-apply'"
"Apply plugin org.gradle.testing.base.plugins.TestSuiteBasePlugin to root project 'plugin-apply'"
"Apply plugin org.gradle.kotlin.dsl.provider.plugins.KotlinScriptBasePlugin to root project 'plugin-apply'"

Why split things so finely? Compare what JavaBasePlugin and JavaPlugin do. JavaBasePlugin only sets up a rule: "whenever a source set appears, register compileJava-style tasks for it".

    private void configureSourceSetDefaults(Project project, final JavaPluginExtension javaPluginExtension) {
        javaPluginExtension.getSourceSets().all(sourceSet -> {

            ConfigurationContainer configurations = project.getConfigurations();

            defineConfigurationsForSourceSet(sourceSet, (RoleBasedConfigurationContainerInternal) configurations);
            definePathsForSourceSet(sourceSet, project);

            createProcessResourcesTask(sourceSet, sourceSet.getResources(), project);
            TaskProvider<JavaCompile> compileTask = createCompileJavaTask(sourceSet, sourceSet.getJava(), project);
            createClassesTask(sourceSet, project);

            configureLibraryElements(compileTask, sourceSet, configurations);
            configureTargetPlatform(compileTask, sourceSet, configurations);
        });
    }

platforms/jvm/plugins-java-base/src/main/java/org/gradle/api/plugins/JavaBasePlugin.java lines 189–204

It's JavaPlugin that actually creates the source set (sourceSets.create(SourceSet.MAIN_SOURCE_SET_NAME), JavaPlugin.java line 301). At that moment the all(…) rule above fires and compileJava is registered. In other words, "capability" (the base plugin) is separated from "convention" (the convention plugin). A build that doesn't need the main/test conventions (for example the Kotlin or Groovy plugins, or a build that creates its own source sets) can apply just java-base and borrow the rules.

Easy to get confused

8. buildSrc and precompiled script plugins

How was lab.hello in Example 2 resolved without any repository? The answer lies before the root project is configured.

    @Override
    public void prepareProjects(GradleInternal gradle) {
        // Setup classloader for root project, all other projects will be derived from this.
        SettingsInternal settings = gradle.getSettings();
        ClassLoaderScope settingsClassLoaderScope = settings.getClassLoaderScope();
        ClassLoaderScope buildSrcClassLoaderScope = settingsClassLoaderScope.createChild("buildSrc[" + gradle.getIdentityPath() + "]", null);
        gradle.setBaseProjectClassLoaderScope(buildSrcClassLoaderScope);
        // ... generate version catalog accessors ...
        // attaches root project
        buildLoader.load(gradle.getSettings(), gradle);
        // ... register included build substitutions ...

        // Build buildSrc and export classpath to root project
        buildBuildSrcAndLockClassloader(gradle, buildSrcClassLoaderScope);

        delegate.prepareProjects(gradle);
        // ...
    }

    private void buildBuildSrcAndLockClassloader(GradleInternal gradle, ClassLoaderScope baseProjectClassLoaderScope) {
        ClassPath buildSrcClassPath = buildSourceBuilder.buildAndGetClassPath(gradle);
        baseProjectClassLoaderScope.export(buildSrcClassPath).lock();
    }

subprojects/core/src/main/java/org/gradle/configuration/BuildTreePreparingProjectsPreparer.java lines 48–73

It creates a buildSrc[:] scope as a child of the settings scope and makes it "the base scope for projects". Then BuildSourceBuilder.buildAndGetClassPath() runs buildSrc as a separate build (the Build buildSrc build operation, BuildSourceBuilder.java lines 61–90), and the resulting jar is exported into that scope, which is then locked. The root project's scope root-project[:] is created underneath it (InstantiatingBuildLoader.java lines 38–42), so from the root build.gradle.kts, the buildSrc jar is a classpath that's already in the parent scope. That's where AlreadyOnClasspathPluginResolver from section 2 found META-INF/gradle-plugins/lab.hello.properties. It's also why :buildSrc:jar printed before > Configure project : in the example output.

The buildSrc project gets the java-library and groovy plugins plus the gradleApi() and localGroovy() dependencies without you writing anything. GroovyBuildSrcProjectConfigurationAction is a four-line action that does exactly those four things (lines 27–35). The > Task :buildSrc:compileGroovy NO-SOURCE in the example output is a trace of it: there's no Groovy source, but the groovy plugin is applied, so the task exists.

The tree of classloader scopes that plugins land in. Under the settings[:] scope (plugins of settings.gradle.kts) is the buildSrc[:] scope. The classpath of the buildSrc build (Build buildSrc operation, compileJava → pluginDescriptors → jar) goes into this scope, and BuildTreePreparingProjectsPreparer exports it and then locks it. Below it, the root-project[:] scope exports the jars fetched by the root build.gradle.kts plugins block, and below that is the scope of a subproject, project-app. AlreadyOnClasspathPluginResolver looks for descriptors in the parent scope. The yellow node is where the buildSrc jar lands. Lower nodes can see the classes of upper nodes, but not the other way round.

This tree resembles the classloader hierarchy in Tomcat post 4. Just as in Tomcat each web app has its own classloader under the common one and web apps can't see each other's classes, in Gradle a subproject sees the root's and buildSrc's plugins but not the jars a sibling project fetched with plugins {}. The difference is direction: Tomcat's web app classloader looks in its own classes first by default (child-first), while the plugin resolution we saw here uses what's already in the parent scope if it's there (AlreadyOnClasspathPluginResolver is at the front of the chain).

Precompiled script plugins: how one script file becomes a plugin

Apply the kotlin-dsl plugin in buildSrc and put a script such as src/main/kotlin/lab.greeting.gradle.kts there, and that one file becomes a plugin with id lab.greeting. This is called a precompiled script plugin. The rule lives in PrecompiledScriptPlugin.

        val simplePluginAdapterClassName = fileNameWithoutScriptExtension
            .kebabCaseToPascalCase()
            .asJavaIdentifier() + "Plugin"
        // ...
            id = packagePrefixed(fileNameWithoutScriptExtension),
            implementationClass = packagePrefixed(simplePluginAdapterClassName),

platforms/core-configuration/kotlin-dsl-provider-plugins/src/main/kotlin/org/gradle/kotlin/dsl/provider/plugins/precompiled/PrecompiledScriptPlugin.kt lines 56–76

The file name without .gradle.kts is the id, and that name turned into a Java identifier with Plugin appended is the adapter class. The adapter is a tiny Plugin implementation generated by the generateScriptPluginAdapters task; its apply() only instantiates the compiled script class (GenerateScriptPluginAdapters.kt lines 89–100). It is then declared with gradlePlugin { plugins.create(id) { … } } (DefaultPrecompiledScriptPluginsSupport.kt lines 518–527), so the pluginDescriptors task from section 3 writes its descriptor. In the end it has exactly the same shape as a hand-written plugin.

Example 3: what does lab.greeting.gradle.kts turn into?

buildSrc's build script is just plugins { `kotlin-dsl` } and repositories { gradlePluginPortal() } (example ex/u07-precompiled/project/buildSrc/build.gradle.kts). Here are the script plugin and the root build script that applies it.

// Example written for this post (not part of Gradle's source)
// File name lab.greeting.gradle.kts -> plugin id "lab.greeting"
println("[lab.greeting] script class = " + javaClass.name)

tasks.register("greet") {
    doLast { println("[task] Hello from lab.greeting") }
}

[Example ex/u07-precompiled/project/buildSrc/src/main/kotlin/lab.greeting.gradle.kts]

// Example written for this post (not part of Gradle's source)
plugins {
    id("lab.greeting")   // buildSrc/src/main/kotlin/lab.greeting.gradle.kts
}

println("[script] plugin classes = " + plugins.map { it.javaClass.name }.filter { !it.startsWith("org.gradle") })

[Example ex/u07-precompiled/project/build.gradle.kts]

After gradle greet --offline --console=plain, we look inside the buildSrc jar (the opening banner lines, the list of buildSrc tasks and the end-of-build lines from BUILD SUCCESSFUL on are cut to ...).

...
> Task :buildSrc:generateScriptPluginAdapters
> Task :buildSrc:pluginDescriptors
...
> Configure project :
[lab.greeting] script class = Lab_greeting_gradle
[script] plugin classes = [Lab_greetingPlugin]

> Task :greet
[task] Hello from lab.greeting
...
=== generated plugin adapter and descriptor ===
Lab_greetingPlugin.class
Lab_greeting_gradle$$$result$1$1.class
Lab_greeting_gradle$$$result$1.class
Lab_greeting_gradle.class
META-INF/gradle-plugins/
META-INF/gradle-plugins/lab.greeting.properties
implementation-class=Lab_greetingPlugin

run.sh also prints the generated adapter's source (Lab_greetingPlugin.kt), which is just the template from lines 89–100 of GenerateScriptPluginAdapters.kt linked above with the names filled in — in apply(target) it calls the constructor of Class.forName("Lab_greeting_gradle") with (target, target), and that's all.

Easy to get confused

What this post leaves out

Summary

Concept One-line summary
plugins {} block Runs on its own before the body and only records requests in PluginRequestCollector. The same id twice fails
applyPlugins() Resolve every request first → add dependencies → export and lock the classloader → then apply in request order
Resolver chain Already on the classpath? → core plugins → TestKit → included builds → repositories. The first to find it wins
Core plugins The org.gradle namespace (or no namespace). A version or apply false is an error
Marker artifact id:id.gradle.plugin:version. Looked up in pluginManagement repositories, otherwise the Plugin Portal
Descriptor implementation-class in META-INF/gradle-plugins/<id>.properties. Written by java-gradle-plugin
java in Kotlin inline val PluginDependenciesSpec.java = id("org.gradle.java"), generated when Gradle is built
DefaultPluginManager Where every application ends up. Guards against applying twice by class and wraps it in an Apply plugin … build operation
PluginInstantiator abstract plugins get a _Decorated subclass; others only get constructor injection
hasPlugin / withPlugin hasPlugin is true even while applying; withPlugin fires after it finishes (since 9.9.0-M2)
Callback order end of apply() → plugins.withType → pluginManager.withPlugin
Extensions extensions.create() makes a generated class and stores it by name in a LinkedHashMap. Duplicate names fail
The java chain JavaPlugin → JavaBasePlugin → BasePlugin → LifecycleBasePlugin, and more. Base plugins give rules, convention plugins give conventions
Auto-applied plugins help-tasks, build-init, wrapper and others are applied to every project by ProjectConfigureActions (ServiceLoader)
buildSrc Built as a separate build before the root project and loaded into the buildSrc[:] scope, so it resolves as "already on the classpath"
Precompiled script plugin a.b.gradle.kts → id a.b, adapter A_bPlugin. In the end, a descriptor plus a Plugin class

What's next

Most of what plugins did in apply() was registering tasks: LifecycleBasePlugin does register("build"), and JavaBasePlugin does register("compileJava") for each source set. But register() doesn't create the task. When is it created? How does configureEach reach "tasks that will exist later"? Why does wiring an extension's Property into a task input create ordering without dependsOn? That's post 8: Tasks and Lazy Configuration — DefaultTask, TaskContainer and Provider. It also explains how DomainObjectSet.all(), which withPlugin relied on in section 5, works.

The earlier series have similar things. withPlugin answers a question similar to @Conditional in Spring post 9 ("if something else is present, configure it this way"), but differently: Spring's condition is evaluated once when the bean definition is registered, while Gradle's withPlugin is reactive, firing whenever the condition becomes true later. Plugins applying other plugins to build up features also resemble the way modules register hooks in httpd post 6. The difference is that httpd modules come in through LoadModule in the configuration file, while Gradle plugins come in through descriptor files and classloaders.

More posts