De architectuur « Onafhankelijke plugin + consumentenroot »: Waarom worden mijn Gradle-builds geduplicerd?
Gepubliceerd op 14 May 2026
- Het Probleem: Drie Architecturen Die Mijn Tijd Hebben Gekost
- De Oplossing: twee onafhankelijke builds, één verbruikerswortel
- Waarom heeft deze architectuur mij veroverd?
- De anatomie van de submap `{name}-plugin/
- Wat we niet doen
- Vergelijking : De drie architecturen geconfronteerd met dogfooding
- Het DAG-contract: Een Build Racin is nooit van belang sinds een submap
- Conclusie: De schijnbare duplicatie die tijd bespaart
- Referenties
Je kloont een Gradle-plugin repository. Je opent een terminal. Wat typt u daarna ?./gradlew tasks, natuurlijk. Maar in welke map? De hoofdmap? De submodule? Moet je eerst een README lezen om te weten hoe je moet bouwen? Als je twijfelt, zou het Een seconde, de architectuur van het project is kapot.
Zo heb ik dit probleem eenmaal en voor altijd opgelost — en waarom dit patroon is vandaag de handtekening van al mijn Gradle-plugins in`foundry/public/`.
Het Probleem: Drie Architecturen Die Mijn Tijd Hebben Gekost
Voordat ik convergeerde naar het huidige patroon, heb ik getast tussen drie benaderingen klassiek voor het organiseren van een Gradle-plugin. Elk had een fatale gebrek.
Optie 1 : het geïsoleerde plugin (geen dogfooding)
Het project bevat alleen de plugin. Geen voorbeeld van verbruik. Geen project dat het uitoefent. Om het te testen, moet je een extern project aanmaken, y verwijzen naar de plugin via`mavenLocal`of een samengestelde build, en alleen daar controleren of het werkt
$ git clone mon-plugin
$ cd mon-plugin
$ ./gradlew build # le plugin compile
$ # ... et maintenant ? comment je l'essaie ?
|
Een Gradle-plugin zonder voorbeeld van consumptie, is een bibliotheek zonder integratietests. Je weet nooit of de laatste wijziging heeft de gebruikerservaring verbroken. |
Optie 2 : De klassieke monorepo (include(":plugin"))
Gradle`init`genereert`settings.gradle.kts`met`include("plugin")`. Root en het submodule delen dezelfde daemon en dezelfde configuraties, dezelfde catalogi. Handig, maar gekoppeld.
.
├── settings.gradle.kts → include("plugin")
├── build.gradle.kts → plugins { id("mon-plugin") }
├── plugin/
│ └── build.gradle.kts → java-gradle-plugin
└── gradle/
└── libs.versions.toml
Wat knelt:
-
De rootmoetdezelfde versie van Gradle hebben als de submodule
-
`libs.versions.toml`wordt gedeeld — gemeenschappelijke versie-catalogi, afhankelijkheden
die van het ene module naar het andere vluchten
-
Onmogelijk de plugin onafhankelijk van de root te bouwen.
-
De CI moet de twee modules bouwen, zelfs als alleen het plugin is gewijzigd.
Optie 3: de Composite Build (includeBuild())
We scheiden de twee in afzonderlijke Gradle-builds en verbinden ze via includeBuild("mon-plugin")`in`settings.gradle.kts.
Het is al beter. De bouw van de plugin is geïsoleerd. Maar de consument moet expliciet verwijzen naar de externe build — en de uitvoer van `./gradlew tasks`bij de wortel afhankelijk van de juiste configuratie van de composiet. De kloning is niet zero-config: Men moet weten dat de plugin is in een aparte map, dat de root ernaar verwijst, enz.
.
├── settings.gradle.kts → includeBuild("plugin-build/")
├── build.gradle.kts → plugins { id("mon-plugin") }
└── plugin-build/
├── settings.gradle.kts
└── build.gradle.kts
Ik zocht beter. Veel beter.
De Oplossing: twee onafhankelijke builds, één verbruikerswortel
Dit is het patroon dat ik uiteindelijk heb aangenomen :
.
├── 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
|
De sleutel:`{name}-plugin/`is een volledig autonoom Gradle-project. Hij heeft zijn eigen wrapper, zijn eigen settings, zijn eigen catalogus van versies. Het wordt gekloond, gebouwd, getest en gepubliceerd zonder dat de root op de hoogte te zijn van zijn bestaan |
De root, hij, doet niets anders dan een ding : de plugin toepassen
plugins {
alias(libs.plugins.bakery)
}
repositories {
mavenLocal()
mavenCentral()
}
bakery { configPath = file("site.yml").absolutePath }
Drie regels in het geval van`bakery-gradle`. Niets meer. Nul`include(), nul`includeBuild(), nul subproject. Een klassieke Gradle-build die een plugin toepast zoals elk Welke consument zou dat doen?
De werkstroom
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12
left to right direction
package "RACINE (Verbruiker)" #CCFFCC {
usecase "Kloon de opslagplaats" as Clone
usecase "./gradlew taken" as Tasks
usecase "./gradlew bak" as Dogfood
note bottom of Dogfood
Oefen de plugin
Directe feedback
Geen configuratie
end note
}
package "{name}-plugin/ (Onafhankelijke build)" #CCE5FF {
usecase "./gradlew bouw" as BuildPlugin
usecase "./gradlew publishToMavenLocal" as MavenLocal
usecase "./gradlew testen" as TestPlugin
note bottom of BuildPlugin
Geïsoleerde levenscyclus
Unit-tests + Cucumber
Dedicated CI mogelijk
end note
}
Clone -down-> Tasks
Tasks -down-> Dogfood
Dogfood ..> MavenLocal : "afhangt van de plugin\ngepubliceerd lokaal"
BuildPlugin -up-> MavenLocal
TestPlugin -up-> BuildPlugin
@enduml
Het concreet 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.
Waarom heeft deze architectuur mij veroverd?
Drie voordelen die, gecombineerd, de kosten van de « duplicatie » waard zijn :
1. Natief dogfooding, directe feedback
De beste test van een Gradle-plugin is om het te gebruiken. Geen gemockte eenheidstest. Geen`GradleRunner`met een project van test. Een echte build die de plugin toepast op echte bestanden.
$ git clone bakery-gradle
$ cd bakery-gradle
$ ./gradlew bake # ← le plugin est exercé immédiatement
Als de plug‑in stuk is, zegt de root-build het. Geen reden om te gaan. zoek een extern testproject. Dogfooding is de eerste taak gelanceerd door een nieuwe bijdrager. Dat is de definitieve smoke test.
|
De regel is eenvoudig: als de wortel compileert en`./gradlew tasks` toont uw plugin-taken, de plugin is functioneel. Geen verrassing in productie. |
Kloning Zero-Config
Un `git clone && ./gradlew tasks`en de nieuwkomer ziet alles lopen zonder iets te configureren. De`build.gradle.kts`wortel is de levende documentatie van het gebruik van de plugin. Het`site.yml`naast toont de verwachte configuratie.
Vergelijk met het alternatief: een README van drie alinea’s die leg uit hoe je de plugin bouwt en hoe je het project bouwt van test. Een nieuwe contributor leest de README diagonaal, vergist zich, opent een issue — terwijl de informatie zou kunnen zijn uitvoerbaar.
|
De robuustste documentatie is niet degene die men leest. Dat is degene die wevoert uit. De root-build is de documentatie uitvoerbare van de plugin |
3. Geïsoleerde builds, onafhankelijke CI
De plug‑in heeft zijn eigen Gradle‑wrapper, zijn eigen levenscyclus, zijn eigen tests. Je kunt:
-
Gradle upgraden in de plugin zonder de root aan te raken
-
Voeg een afhankelijkheid toe in de plug‑in zonder dat deze in de root lekt.
-
Breken van de plugin zonder de root build te beïnvloeden (zolang je niet
Publiceer de kapotte versie niet)
-
Een CI hebben die de plugin build/test, en een andere die de
root — ongeacht
.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
De anatomie van de submap `{name}-plugin/
Laten we eens nader kijken wat leeft in de pluginmap:
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
Alles is daar. Geen verspreiding tussen de wortel en de submap. De ontwikkelaar die aan de plugin werkt heeft nooit nodig vertrekken`codebase-plugin/`. De ontwikkelaar die de plug-in gebruikt kijkt alleen naar de wortel — en de`build.gradle.kts`van 3 lijnen Hij vertelt hem alles wat hij moet weten.
De Versiecatalogus: twee afzonderlijke bestanden
|
|
|
Aantal afhankelijkheden |
2-3 (plugin + readme eventueel) |
30+ (langchain4j, pgvector, cucumber…) |
Rol |
Consumeer de plugin |
Bouw de plugin |
Wie het leest |
De gebruiker van de plugin |
De ontwikkelaar van de plugin |
De root heeft een bewust minimaal catalogus. De plugin heeft een catalogus volledig. Verwarring is onmogelijk: elke build heeft zijn eigen scope van afhankelijkheden.
|
Als je al een uur hebt besteed aan het debuggen van een afhankelijkheidsconflict tussen uw plug-in en uw testproject begrijpt u de waarde van deze scheiding. Onafhankelijke catalogi elimineren dit probleem door constructie |
Wat we niet doen
Dit patroon is niet magisch. Het legt een beperking op die ik hij/zij toebrengt mij graag:
De root bouwt de plugin niet. U moet`publishToMavenLocal` ou voordat root het kan gebruiken.
Het is een kleine kost, en het is de goede beperking. De root Gebruikt de plugin als een externe client — via Maven. Exact zoals een derde partijproject zou doen. Als de plugin niet publiceerbaar is, de root zegt het je meteen.
# La seule "friction" du pattern
$ cd codebase-plugin && ./gradlew publishToMavenLocal && cd ..
$ ./gradlew tasks --group=codebase
Vergelijking : De drie architecturen geconfronteerd met dogfooding
Geïsoleerde plugin |
Monorepo`include()` |
Samengesteld`includeBuild()` |
Root + onafhankelijke plugin. |
|
`git clone && gradlew tasks`geeft de taken van de plugin |
❌ |
✅ |
Begrepen. |
✅ |
Root-onafhankelijke build-plugin |
✅ |
❌ |
✅ |
✅ |
Gescheiden catalogusversies |
✅ |
❌ |
Begrepen. |
✅ |
Geen`include()` ni |
Ready. |
❌ |
❌ |
✅ |
Native dogfooding zonder config |
❌ |
✅ |
⚠️ |
✅ |
Onafhankelijke CI-plugin |
Begrepen. |
❌ |
✅ |
✅ |
De root is een voorbeeld van een werkelijk verbruik |
❌ |
⚠️ |
✅ |
✅ |
De kolom rechts vinkt alle vakjes aan. Daarom ik niet Ik zal verder terugkomen.
Het DAG-contract: Een Build Racin is nooit van belang sinds een submap
Deze architectuur past zich van nature aan in de DAG N0→N3 van mijn werkruimte. Het patroon is: het plugin N2 is de hub Van zijn eigen afhankelijkheden is de N3-wortel een terminal. die hubs toepast.
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 11
title Overeenkomst DAG — Basis consument vs onafhankelijke plugin
package "N3 — RACINE (depot)" #FFCCCC {
[build.gradle.kts (3 lignes)] as ROOT
[libs.versions.toml (minimal)] as ROOT_TOML
note right of ROOT
plugins { bakery }
bakery { configPath = ... }
Geen zakelijke logica
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
ondertekenen + publiceren
Unittesten + Cucumber
Toegewijde CI
end note
}
ROOT_TOML --> ROOT : "versie plugin"
ROOT -down-> PLUGIN : "publishToMavenLocal"
@enduml
Le codebase-gradle/build.gradle.kts`maakt 6 regels. Geen`src/, geen`buildSrc/, geen`gradle/rag-bench.gradle.kts. rechtvaardig plugins { alias(libs.plugins.codebase) }`en de repositories. De hele complexiteit leeft in`codebase-plugin/.
Conclusie: De schijnbare duplicatie die tijd bespaart
Wanneer ik deze structuur aan iemand laat zien, is de eerste reactie is vaak: « Maar je hebt twee`gradlew`, twee`settings.gradle.kts`, twee`libs.versions.toml`— het is duplicatie !
Ja. En nee.
De 'duplicatie' is het reproduceren van dezelfde informatie op twee plaatsen. Hier zijn twee distincts bestanden die twee distincts doeleinden dienen : de catalogus van de plug-in (30+ afhankelijkheden voor builder) en de catalogus van de wortel (2-3 afhankelijkheden om te consumeren). De wrapper van de plugin (vergrendelde versie voor ontwikkeling) en de wrapper van de hoofdmap (potentieel verschillende versie, voor de plugin-oefening)
Het is de scheiding van de verantwoordelijkheden toegepast op het build-systeem. Elke build Het doet één ding, en één ding alleen. De wortel consumeert. De plugin bouwt.
De kost? Een bestelling`publishToMavenLocal`tussen de build van de plugin en de build van de root. Het voordeel? Een architecturale helderheid die verwijderd uren aan downstream debugging
Sinds ik dit patroon heb geïmplementeerd op`bakery-gradle`, plantuml-gradle, codebase-gradle, en de andere plugins van`foundry/public/, ik heb nooit meer getwijfeld bij het openen van een terminal in een van mijn repositories. De eerste reflex —./gradlew tasks`— loopt altijd, geeft altijd de juiste taken, en zegt me onmiddellijk of alles gezond is.
Dat is het, de goede architectuur. Je leest het niet in een README. Het wordt getest in een terminal, in minder dan tien seconden.