reading time : 11 minutes

You clone a Gradle plugin repository. You open a terminal. What do you type next?./gradlew tasks, of course. But in which folder? The root? The sub-module? Should one first read a README to know how to build? If you hesitate, wouldn’t in a second, the project architecture is broken.

Here’s how I solved this problem once and for all — and why this pattern is today the signature of all my Gradle plugins in`foundry/public/`.

The Problem: Three Architectures That Made Me Waste Time

Before converging to the current pattern, I groped between three approaches Classic ways to organize a Gradle plugin. Each had a fatal flaw.

Option 1: The Isolated Plugin (no dogfooding)

The project contains only the plugin. No example of consumption. No project that it exercises. To test it, you must create an external project, y reference the plugin via`mavenLocal`or a composite build, and only there check that it works.

$ git clone mon-plugin
$ cd mon-plugin
$ ./gradlew build          # le plugin compile
$ # ... et maintenant ? comment je l'essaie ?

A Gradle plugin without a consumption example is a library without integration tests. You never know if the latest change a broken the user experience.

Option 2: The Monorepo Classic (include(":plugin\"))

Gradle`init`generates`settings.gradle.kts`with`include("plugin")`. The root and the sub-module share the same daemon, the same configurations, the same catalogs. Practical, but coupled.

.
├── settings.gradle.kts   → include("plugin")
├── build.gradle.kts      → plugins { id("mon-plugin") }
├── plugin/
│   └── build.gradle.kts  → java-gradle-plugin
└── gradle/
    └── libs.versions.toml

What’s the snag:

  • The rootmusthave the same Gradle version as the submodule.

  • `libs.versions.toml`is shared — common version catalogs, dependencies

who flee from one module to another

  • Cannot build the plugin independently of the root.

  • The CI must build the two modules, even if only the plugin has changed.

Option 3: The Composite Build (includeBuild()) The Composite Build

We separate the two into distinct Gradle builds and we connect them via includeBuild("mon-plugin")`in`settings.gradle.kts.

This is already better. The plugin build is isolated. But the consumer must explicitly reference the external build — and the output of `./gradlew tasks`at the root depends on the proper configuration of the composite. Cloning is not zero-config : you need to know that the plugin is in a separate folder, the root references it, etc.

.
├── settings.gradle.kts   → includeBuild("plugin-build/")
├── build.gradle.kts      → plugins { id("mon-plugin") }
└── plugin-build/
    ├── settings.gradle.kts
    └── build.gradle.kts

I was looking for better. Much better.

The Solution: Two Independent Builds, One Consumer Root

Here is the pattern I ended up adopting:

.
├── settings.gradle.kts          ← racine consommateur
├── build.gradle.kts              ← 3 lignes : apply plugin + dogfood
├── gradle/
│   └── libs.versions.toml        ← catalogue du consommateur
├── {name}-plugin/                ← BUILD INDÉPENDANT
│   ├── gradlew                    ← son propre wrapper
│   ├── settings.gradle.kts        ← rootProject.name = "{name}-plugin"
│   ├── build.gradle.kts           ← java-gradle-plugin, signing, publish
│   ├── gradle/
│   │   ├── libs.versions.toml     ← catalogue du plugin
│   │   └── wrapper/
│   ├── src/                       ← sources du plugin
│   ├── .agents/                   ← gouvernance agent
│   └── *.adoc                     ← AGENT, PROMPT_REPRISE, snapshot, etc.
└── site.yml / slides-context.yml / ...   ← configs dogfood

The key :`{name}-plugin/`is a fully self-contained Gradle project. It has its own wrapper, its own settings, its own catalog of versions. It clones, builds, tests, and publishes itself without the root be aware of his/her existence.

The root, on the other hand, only does one thing: apply the plugin.

plugins {
    alias(libs.plugins.bakery)
}

repositories {
    mavenLocal()
    mavenCentral()
}

bakery { configPath = file("site.yml").absolutePath }

Three lines in the case of`bakery-gradle`. Nothing more. Zero`include(), zero`includeBuild(), zero subproject. A classic Gradle build that applies a plugin like any Which consumer would do that?

The Workflow

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12

left to right direction

package "ROOT (Consumer)" #CCFFCC {
    usecase "Clone the repository" as Clone
    usecase "./gradlew tasks" as Tasks
    usecase "./gradlew bake" as Dogfood
    note bottom of Dogfood
        Exerce le plugin
        Feedback immédiat
        Zéro config
    end note
}

package "{name}-plugin/ (Independent Build)" #CCE5FF {
    usecase "./gradlew build" as BuildPlugin
    usecase "./gradlew publishToMavenLocal" as MavenLocal
    usecase "./gradlew test" as TestPlugin
    note bottom of BuildPlugin
        Cycle de vie isolé
        Tests unitaires + Cucumber
        CI dédiée possible
    end note
}

Clone -down-> Tasks
Tasks -down-> Dogfood
Dogfood ..> MavenLocal : "depends on the plugin\npublished locally"
BuildPlugin -up-> MavenLocal
TestPlugin -up-> BuildPlugin

@enduml

The Concrete Workflow

# 1. Builder le plugin
$ cd codebase-plugin
$ ./gradlew publishToMavenLocal

# 2. L'exercer depuis la racine
$ cd ..
$ ./gradlew indexCodebase queryCodebase snapshot

# La boucle est fermée. Le plugin est testé dans des conditions
# réelles de consommation, par le projet même qui l'héberge.

Why This Architecture Won Me Over

Three benefits that, combined, are worth the cost of 'duplication':

Native Dogfooding, Instant Feedback

The best test of a Gradle plugin is to use it. Not a mocked unit test. Not a`GradleRunner`with a project of test. A real build that applies the plugin on real files.

$ git clone bakery-gradle
$ cd bakery-gradle
$ ./gradlew bake    # ← le plugin est exercé immédiatement

If the plugin is broken, the root build says so. No need to go look for an external test project. Dogfooding is the first Task that a new contributor launches. It is the definitive smoke test.

The rule is simple: if the root compiles and that`./gradlew tasks` Displays your plugin tasks, the plugin is functional. No surprise. in production.

2. Zero‑config cloning

Un `git clone && ./gradlew tasks`and the newcomer sees everything walk without configuring anything. The`build.gradle.kts`root is the living usage documentation of the plugin. The`site.yml`next to shows the expected configuration

Compare with the alternative: a three‑paragraph README that explain how to build the plugin and how to build the project of test. A new contributor skims the README, is mistaken, opens an issue—while the information could to be executable

The most robust documentation isn’t the one that is read. It’s the one that weexecute. The root build is the documentation plugin executable

3. Isolated Builds, Independent CI

The plugin has its own Gradle wrapper, its own lifecycle, its own tests. You can:

  • Upgrade Gradle in the plugin without touching the root

  • Add a dependency in the plugin without it leaking into the root

  • Break the plugin without impacting the root build (as long as you don

Don’t publish the broken version)

  • Having a CI that builds/tests the plugin, and another that exercises the

root — independently

.github/workflows/
├── test-plugin.yml      → codebase-plugin/.gradlew build
├── test-root.yml        → .gradlew tasks (vérifie que le plugin est consommable)
└── publish.yml          → codebase-plugin/.gradlew publish

Anatomy of the {name}-plugin/ subfolder

Let’s take a closer look at what lives in the plugin folder:

codebase-plugin/
├── gradlew                           ← wrapper indépendant
├── settings.gradle.kts               ← @Suppress("UnstableApiUsage")
│                                       + foobar-resolver-convention
├── build.gradle.kts                  ← java-gradle-plugin + signing + publish
├── gradle/
│   ├── libs.versions.toml            ← catalogue complet (langchain4j, pgvector...)
│   └── wrapper/
├── buildSrc/                         ← classes utilitaires buildSrc
│   ├── build.gradle.kts
│   └── src/main/kotlin/
│       ├── codebase/                 ← CodebaseYmlAnonymizer, CodebaseConfiguration
│       ├── benchmark/                ← BenchmarkConfig, BenchmarkProtocol
│       ├── readme/                   ← ReadmeYmlAnonymizer
│       ├── site/                     ← SiteYmlAnonymizer
│       ├── slider/                   ← SliderYmlAnonymizer
│       └── snapshot/                 ← SnapshotManager
├── src/
│   ├── main/kotlin/codebase/
│   │   ├── CodebasePlugin.kt         ← class Plugin<Project>
│   │   ├── rag/                      ← pgvector, embedding, anonymization...
│   │   ├── benchmark/                ← BenchmarkRunner, export, comparison...
│   │   └── walker/                   ← WorkspaceWalker
│   └── test/
│       ├── kotlin/codebase/scenarios/ ← steps Cucumber
│       ├── features/                  ← .feature files
│       └── resources/datasets/        ← fixtures .adoc, .yml, .json
├── .agents/                          ← gouvernance agent (INDEX, SESSIONS, etc.)
├── AGENT.adoc                        ← règles agent
├── PROMPT_REPRISE.adoc              ← mission session
├── BACKLOG.adoc                      ← backlog produit
├── snapshot.adoc                     ← snapshot auto-généré du projet
└── embeds.yml                        ← config RAG embeds

Everything is here. No dispersion between the root and the subfolder. The developer working on the plugin never needs to leave`codebase-plugin/`. The developer who uses the plugin only looks at the root — and the`build.gradle.kts`of 3 lines Tell him everything he needs to know.

The Version Catalog: Two Distinct Files

gradle/libs.versions.toml(root)

{name}-plugin/gradle/libs.versions.toml

Number of dependencies

2-3 (plugin + optionally readme)

30+ (langchain4j, pgvector, cucumber…​)

Role

Consume the plugin

Build the plugin

Who reads it

The plugin user

The plugin developer

The root has an intentionally minimal catalog. The plugin has a catalog complete. Confusion is impossible: each build has its own scope of dependencies.

If you have already spent an hour debugging a dependency collision between your plugin and your test project, you understand the value of this separation. Independent catalogs eliminate this problem by construction.

What we do not do

This pattern is not magical. It imposes a constraint that I willingly inflicts on me:

Root does not build the plugin. You must`publishToMavenLocal` ou deploy on a repository before the root can consume it

It’s a minimal cost, and it’s the right constraint. The root consumes the plugin as an external client — via Maven. Exactly as a third-party project would. If the plugin is not publishable, the root tells you immediately.

# La seule "friction" du pattern
$ cd codebase-plugin && ./gradlew publishToMavenLocal && cd ..
$ ./gradlew tasks --group=codebase

Comparison : The Three Architectures Facing Dogfooding

Isolated plugin

Monorepo`include()`

Composite`includeBuild()`

Root + independent plugin.

`git clone && gradlew tasks`gives the tasks of the plugin

�❌

[No French text was provided for translation. The input contained only instructions and a checkmark symbol, which is not French text to translate.]

✅

✅

Root-independant build plugin

�✅

�❌

✅

✅

Separate catalogue versions

✅

❌

✅

�✅

No`include()` ni includeBuild()

✅

�❌

�❌

�✅

Native dogfood without configuration

�❌

✅

⚠️

�✅

Independent CI plugin

✅

❌

✅

✅

Root is an example of real consumption

❌

⚠️

Could you please provide the French text to translate?

�✅

The right column checks all the boxes. That’s why I don’t I will go further back.

The DAG Contract: A Root Build Never Comes From a Subfolder

This architecture naturally integrates into the DAG N0→N3 of my workspace. The pattern is: plugin N2 is the hub From its own dependencies, the N3 root is a terminal who applies hubs.

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 11

title DAG Contract — Consumer Root vs Independent Plugin
package "N3 — ROOT (deposit)" #FFCCCC {
    [build.gradle.kts (3 lignes)] as ROOT
    [libs.versions.toml (minimal)] as ROOT_TOML
    note right of ROOT
        plugins { bakery }
        bakery { configPath = ... }
        Aucune logique métier
    end note
}

package "N2 — {name}-plugin/" #CCFFCC {
    [build.gradle.kts (complet)] as PLUGIN
    [src/main/kotlin/] as SRC
    [gradle/libs.versions.toml] as PLUGIN_TOML
    [buildSrc/] as BUILDSRC
    note bottom of PLUGIN
        java-gradle-plugin
        signing + publish
        Tests unitaires + Cucumber
        CI dédiée
    end note
}

ROOT_TOML --> ROOT : "plugin version"
ROOT -down-> PLUGIN : "publishToMavenLocal"
@enduml

Le codebase-gradle/build.gradle.kts`is 6 lines. No`src/, no`buildSrc/, no`gradle/rag-bench.gradle.kts. Just plugins { alias(libs.plugins.codebase) }`and the repositories. All the complexity lives in`codebase-plugin/.

Conclusion : The Apparent Duplication That Saves Time

When I show this structure to someone, the first reaction is often: "But you have two`gradlew`, two`settings.gradle.kts`, two`libs.versions.toml`— this is duplication !

Yes. And no.

Duplication is reproducing the same information in two places. Here, these are two distinct files serving two distinct uses : the plugin catalog (30+ dependencies for builder) and the catalog from the root (2-3 dependencies to consume). The plugin wrapper (locked version for development) and the root wrapper (potentially different version, for the exercise of the plugin).

It’s not duplication. It’s separation of responsibilities applied to the build system. Each build It does one thing, and only one. The root consumes. The plugin builds itself.

The cost? An order`publishToMavenLocal`between the build of the plugin and the build of the root. The gain? An architectural clarity that Eliminates hours of downstream debugging.

Since I deployed this pattern on`bakery-gradle`, plantuml-gradle, codebase-gradle, and the other plugins of`foundry/public/, I don’t have I never again hesitated when opening a terminal in one of my repositories. The first reflex —./gradlew tasks`— still walking, still giving the good tasks, and instantly tells me if everything is healthy

That’s it, good architecture. It cannot be read in a README. It is experienced in a terminal, in less than ten seconds.

Related articles