The "Independent Plugin + Consumer Root" Architecture: Why My Gradle Builds Are Doubling
Published on 14 May 2026
- The Problem: Three Architectures That Wasted My Time
- The Solution: Two Independent Builds, One Consumer Root
- Why This Architecture Won Me Over
- The Anatomy of the
{name}-plugin/Sub-folder - What We Don’t Do
- Comparison: The Three Architectures Facing Dogfooding
- The DAG Contract: A Root Build Never Imports from a Sub-folder
- Conclusion: The Apparent Duplication That Saves Time
- References
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? Do you first have to read a README to know how to build? If you hesitate for even a second, the project architecture is broken.
Here is how I solved this problem once and for all — and why this pattern is now the signature of all my Gradle plugins in`foundry/public/`.
The Problem: Three Architectures That Wasted My Time
Before converging on the current pattern, I experimented with three classic approaches for organizing a Gradle plugin. Each had a deal-breaking flaw.
Option 1: The Isolated Plugin (no dogfooding)
The project contains only the plugin. No consumption example. No project that exercises it. To test it, you must create an external project, reference the plugin via`mavenLocal`or a composite build, and only then verify 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 like a library without integration tests. You never know if the latest change has broken the user experience. |
Option 2: The Classic Monorepo (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. Convenient, 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
Where it fails:
-
The rootmusthave the same Gradle version as the sub-module.
-
`libs.versions.toml`is shared — common version catalogs, dependencies
leaking from one module to another.
-
Impossible to build the plugin independently of the root.
-
The CI must build both modules, even if only the plugin has changed.
Option 3: The Composite Build (includeBuild())
We separate the two into distinct Gradle builds and link 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 correct configuration of the composite. Cloning is not zero-config: you need to know that the plugin is in a separate folder, that 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 something better. Much better.
The Solution: Two Independent Builds, One Consumer Root
Here is the pattern I eventually adopted:
.
├── 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 complete and autonomous Gradle project. It has its own wrapper, its own settings, its own version catalog. It clones, builds, tests, and publishes without the root being aware of its existence. |
The root, meanwhile, does only 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 sub-projects. A classic Gradle build that applies a plugin just as any consumer would.
The Workflow
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":
1. Native Dogfooding, Instant Feedback
The best test for a Gradle plugin is to use it. Not a mocked unit test. Not a`GradleRunner`with a test project. A real build applying the plugin to 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 a new contributor runs. It is the definitive smoke test.
|
The rule is simple: if the root compiles and`./gradlew tasks` displays your plugin tasks, the plugin is functional. No surprises in production. |
2. Zero-Config Cloning
Un `git clone && ./gradlew tasks`and the newcomer sees everything working without configuring anything. The`build.gradle.kts`root is the living documentation of the plugin’s usage. The`site.yml`next to it shows the expected configuration.
Compare this with the alternative: a three-paragraph README that explains how to build the plugin AND how to build the test project. A new contributor skims the README, makes a mistake, opens an issue — when the information could be executable.
|
The most robust documentation isn’t the one we read. It’s the one weexecute. The root build is the plugin’s executable documentation. |
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’t publish the broken version)
-
Have one 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
The Anatomy of the {name}-plugin/ Sub-folder
Let’s look closer 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 there. No dispersion between the root and the sub-folder. The developer working on the plugin never needs to leave`codebase-plugin/`. The developer using the plugin only looks at the root — and the`build.gradle.kts`of 3 lines tells them everything they need to know.
The Version Catalog: Two Distinct Files
|
|
|
Number of dependencies |
2-3 (plugin + readme eventually) |
30+ (langchain4j, pgvector, cucumber…) |
Role |
Consume the plugin |
Build the plugin |
Who reads it |
The plugin user |
The plugin developer |
The root has a deliberately minimal catalog. The plugin has a complete catalog. Confusion is impossible: each build has its own dependency scope.
|
If you’ve ever 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 design. |
What We Don’t Do
This pattern isn’t magic. It imposes a constraint that I willingly accept:
The root does not build the plugin. You must`publishToMavenLocal` ou deploy to a repository before the root can consume it.
This is a minimal cost, and it’s the right constraint. The root consumes the plugin like an external client — via Maven. Exactly as a third-party project would. If the plugin isn’t 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 + indep. plugin |
|
`git clone && gradlew tasks`gives the plugin tasks |
❌ |
✅ |
✅ |
✅ |
Plugin build independent of root |
✅ |
❌ |
✅ |
✅ |
Separate version catalog |
✅ |
❌ |
✅ |
✅ |
No`include()` ni |
✅ |
❌ |
❌ |
✅ |
Native dogfooding without config |
❌ |
✅ |
⚠️ |
✅ |
Independent plugin CI |
✅ |
❌ |
✅ |
✅ |
Root is a real consumption example |
❌ |
⚠️ |
✅ |
✅ |
The right column checks all the boxes. That’s why I’m not going back.
The DAG Contract: A Root Build Never Imports from a Sub-folder
This architecture integrates naturally into the N0→N3 DAG of my workspace. The pattern is: the N2 plugin is the hub of its own dependencies, the N3 root is a terminal that applies hubs.
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`— that’s duplication!"
Yes. And no.
"Duplication" is reproducing the same information in two places. Here, these are two distinct files serving two distinct purposes: the plugin catalog (30+ dependencies to build) and the root catalog (2-3 dependencies to consume). The plugin wrapper (locked version for development) and the root wrapper (potentially different version, for exercising the plugin).
This is not duplication. This is separation of concerns applied to the build system. Each build does one thing, and one thing only. The root consumes. The plugin builds.
The cost? One`publishToMavenLocal`command between the plugin build and the root build. The gain? Architectural clarity that removes hours of downstream debugging.
Since I deployed this pattern on`bakery-gradle`, plantuml-gradle, codebase-gradle, and other`foundry/public/plugins, I have never hesitated again when opening a terminal in one of my repos. The first reflex —./gradlew tasks`— always works, always gives the right tasks, and tells me instantly if everything is healthy.
That is what good architecture is. It isn’t read in a README. It is proven in a terminal, in less than ten seconds.