L'architettura « Plugin Indipendente + Radice Consumatore » : Perché i miei build Gradle si duplicano
Publié le 14 May 2026
Cloni un repository di plugin Gradle. Apri un terminale. Cosa digiti? poi?./gradlew tasks, certo. Ma in quale cartella? La radice? Il sottomodulo? Bisogna prima leggere un README per sapere come costruire? Se voi esitate, non sarebbe un attimo, l’architettura del progetto è rotta.
Ecco come ho risolto questo problema una volta per tutte—e perché questo pattern è oggi la firma di tutti i miei plugin Gradle in`foundry/public/`.
Il Problema: Tre Architetture Che Mi Hanno Fatto Perdere Tempo
Prima di convergere verso il pattern attuale, ho vagato tra tre approcci classici per organizzare un plugin Gradle. Ognuna aveva un difetto fatale.
# Integrazione Gradle
JBake può essere integrato nelle build Gradle utilizzando il plugin Gradle di JBake o chiamando direttamente l’interfaccia a riga di comando di JBake:
----
tasks.register<JavaExec>("bake") {
mainClass.set("org.jbake.launcher.Main")
classpath = configurations["jbake"]
args = listOf(projectDir.absolutePath, "$buildDir/jbake")
}
----
Opzione 1: Il Plugin Isolato (nessun dogfooding)
Il progetto contiene solo il plugin. Nessun esempio di consumo. Nessun progetto che lo esercita. Per testarlo, è necessario creare un progetto esterno, y referenziare il plugin tramite`mavenLocal`o un composite build, e solo lì verificare che funzioni.
[source,shell]
----
$ git clone mon-plugin
$ cd mon-plugin
$ ./gradlew build # le plugin compile
$ # ... et maintenant ? comment je l'essaie ?
----
[WARNING]
====
Un plugin Gradle senza un esempio di consumo, è una libreria senza test di integrazione. Non sai mai se l'ultima modifica a rovinato l'esperienza utente.
====
=== Opzione 2 : Il Monorepo Classico (`include(":plugin")`)
Gradle `init`genera`settings.gradle.kts`con`include("plugin")`. Il root e il sottomodulo condividono lo stesso demone, le stesse configurazioni, gli stessi cataloghi. Pratico, ma collegato.
[source,text]
----
.
├── settings.gradle.kts → include("plugin")
├── build.gradle.kts → plugins { id("mon-plugin") }
├── plugin/
│ └── build.gradle.kts → java-gradle-plugin
└── gradle/
└── libs.versions.toml
----
Il problema:
* Il root**deve**avere la stessa versione di Gradle del sottomodulo.
* `libs.versions.toml`è condiviso — versioni cataloghi comuni, dipendenze
che fuggono da un modulo all'altro.
* Impossibile costruire il plugin indipendentemente dalla radice.
* Il CI deve costruire i due moduli, anche se solo il plugin è cambiato.
=== Opzione 3: Build composito (`includeBuild()`)
Si separano i due in build Gradle distinti e li colleghiamo tramite `includeBuild("mon-plugin")`in`settings.gradle.kts`.
È già meglio. Il build del plugin è isolato. Ma il consumatore deve esplicitamente fare riferimento al build esterno — e l'output di `./gradlew tasks`alla radice dipende dalla corretta configurazione del composito. Il clonaggio non è zero-config: bisogna sapere che il Il plugin si trova in una cartella a parte, che il root lo riferisce, ecc.
[source,shell]
----
.
├── settings.gradle.kts → includeBuild("plugin-build/")
├── build.gradle.kts → plugins { id("mon-plugin") }
└── plugin-build/
├── settings.gradle.kts
└── build.gradle.kts
----
Stavo cercando meglio. Molto meglio.
== La Soluzione: Due Build Indipendenti, Una Radice Consumatore
Ecco il pattern che ho finito per adottare:
[source,text]
----
.
├── 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
----
[IMPORTANT]
====
La chiave :`{name}-plugin/`è un progetto Gradle *completo e autonomo*. Ha il suo wrapper, le sue impostazioni, il suo catalogo di versioni. Si clona, si build, si testa, e si pubblica senza che il root sia al corrente della sua esistenza.
====
Il root, lui, fa solo *una* cosa: applicare il plugin.
[source,kotlin]
----
plugins {
alias(libs.plugins.bakery)
}
repositories {
mavenLocal()
mavenCentral()
}
bakery { configPath = file("site.yml").absolutePath }
----
Tre righe nel caso di`bakery-gradle`. Niente di più. Zero`include()`, zero`includeBuild()`, zero sottoprogetto Un build Gradle classico che applica un plugin come qualsiasi Quale consumatore lo farebbe.
=== Il flusso di lavoro
[plantuml,architecture-deux-builds,svg]
----
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12
left to right direction
package "RADICE (Consumatore)" #CCFFCC {
usecase "Clona il repository" as Clone
usecase "### Integrazione Gradle
JBake può essere integrato nelle build Gradle utilizzando il plugin Gradle di JBake o chiamando direttamente l'interfaccia a riga di comando di JBake:
```kotlin
tasks.register<JavaExec>("bake") {
mainClass.set("org.jbake.launcher.Main")
classpath = configurations["jbake"]
args = listOf(projectDir.absolutePath, "$buildDir/jbake")
}
usecase "./gradlew bake" as Dogfood
note bottom of Dogfood
Exerce le plugin
Feedback immédiat
Zéro config
end note
}
package "{name}-plugin/ (Build indipendente)" #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 : "dipende dal plugin pubblicato localmente" BuildPlugin -up→ MavenLocal TestPlugin -up→ BuildPlugin
@enduml
=== Il workflow concreto [source,shell]
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.
== Perché questa architettura mi ha conquistato Tre benefici che, combinati, valgono il costo della « duplicazione» : === 1. Dogfooding nativo, feedback istantaneo Il miglior test di un plugin Gradle è usarlo. Non un test unitario mockato. Non un`GradleRunner`con un progetto di test. Un *vero* build che applica il plugin su file reali. [source,shell]
$ git clone bakery-gradle $ cd bakery-gradle $ ./gradlew bake # ← le plugin est exercé immédiatement
Se il plugin è rotto, il build radice lo dice. Non c'è bisogno di andare cercare un progetto di test esterno. Il dogfooding è la *prima* compito lanciato da un nuovo contributore. È il smoke test definitivo. [TIP] ==== ### JBake CLI Commands ``` # Initialize a new JBake project jbake -i # Bake (generate) the site jbake -b # Bake and serve locally jbake -b -s # Bake and watch for changes jbake -b --reset # Specify source and destination jbake source_folder output_folder # Clear the output directory before baking jbake -b . output --reset ``` La regola è semplice : se la radice compila e`./gradlew tasks` mostra i tuoi task del plugin, il plugin è funzionante. Nessuna sorpresa in produzione. ==== === 2. Clonaggio Zero-Config Un `git clone && ./gradlew tasks`e il nuovo arrivato vede tutto camminare senza configurare nulla. Il`build.gradle.kts`radice è la documentazione vivente d'uso del plugin. Il`site.yml`accanto mostra la configurazione attesa. Confronta con l'alternativa : un README di tre paragrafi che spiega come builder il plugin e come builder il progetto di test. Un nuovo contributore legge il README in diagonale, apre un'issue — mentre l'informazione potrebbe essere *eseguibile*. [IMPORTANT] ==== La documentazione più robusta non è quella che si legge. È quella che**esegui**. Il build radice è la documentazione eseguibile del plugin. ==== === 3. Builds Isolés, CI Indépendants Il plugin ha il suo wrapper Gradle proprio, il suo ciclo di vita proprio, i suoi test. Puoi : * Aggiornare Gradle nel plugin senza toccare la root * Aggiungere una dipendenza nel plugin senza che perda nella radice * Rompere il plugin senza impattare il build radice (finché tu non non pubblicare la versione rotta) * Avere una CI che build/test il plugin, e un'altra che esercita il root — indipendentemente [source,text]
├── 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
== L'anatomia della sottocartella `{name}-plugin/
Diamo un'occhiata più da vicino a ciò che vive nella cartella del plugin :
[source,text]
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
Tutto è qui. Nessuna dispersione tra la radice e la sottocartella. Le développeur qui travaille sur le plugin n'a jamais besoin de lasciare`codebase-plugin/`. Lo sviluppatore che utilizza il plugin guarda solo alla radice — e il`build.gradle.kts`di 3 righe gli dice tutto ciò di cui ha bisogno di sapere
=== Il Catalogo delle Versioni : Due File Distinti
[cols="2,3,3"]
|===
| |`gradle/libs.versions.toml`(radice) |`{name}-plugin/gradle/libs.versions.toml` |Numero di dipendenze |2-3 (plugin + readme eventualmente) |30+ (langchain4j, pgvector, cucumber...) |Ruolo |Consumare il plugin |Builder il plugin |Chi lo legge |L'utente del plugin |Il sviluppatore del plugin
|===
Il root ha un catalogo intenzionalmente minimo. Il plugin ha un catalogo completo. La confusione è impossibile: ogni build ha il proprio ambito dipendenze.
[NOTE]
====
Se hai già passato un'ora a debuggare una collisione di dipendenze tra il tuo plugin e il tuo progetto di test, comprendi il valore di questa separazione. I cataloghi indipendenti eliminano questo problema per costruzione.
====
== Ciò che non si fa
Questo pattern non è magico. Impone un vincolo che io mi infligge volentieri :
*Il root non effettua il build del plugin.* Devi`publishToMavenLocal` ou distribuire su un repository prima che il root possa consumarlo
È un costo minimo, e questa è la *buona* vincolo. Il root consuma il plugin come un client esterno — tramite Maven. Esattamente come farebbe un progetto di terze parti. Se il plugin non è pubblicabile, La root Le lo dice immediatamente.
[source,shell]
La seule "friction" du pattern
$ cd codebase-plugin && ./gradlew publishToMavenLocal && cd .. $ ./gradlew tasks --group=codebase
== Confronto: Le tre architetture di fronte al dogfooding [cols="2,2,2,2,2"] |=== | |Plugin isolato |Monorepo`include()` |composito`includeBuild()` |**Radice + plugin indipendente** |`git clone && gradlew tasks`fornisce le attività del plugin |�❌ |</think> (No output) |�✅ |✅ |Plugin di build indipendente dalla radice |(no output) |❌ |�✅ |(No output) |catalogo versioni separato |�✅ |�❌ |(no output) |</think> |nessun`include()` ni `includeBuild()` |✅ |❌ |�❌ |�✅ |Dogfood nativo senza configurazione |�❌ |�✅ |⚠️ |(No output) |plug-in CI indipendente |�✅ |�❌ |�✅ |</think> ✅ |Il root è un esempio di consumo reale |�❌ |(No output) |�✅ |(no output) |=== La colonna di destra spunta tutte le caselle. È per questo che io non tornerò più indietro. == Il Contratto DAG : Una build radice non importa mai da una sottocartella Questa architettura si integra naturalmente nel DAG N0→N3 del mio workspace. Il pattern è : *il plugin N2 è il hub delle sue dipendenze, la radice N3 è un terminale chi applica i hub* [plantuml,contrat-dag-architecture,svg]
@startuml skinparam backgroundColor #FEFEFE skinparam defaultFontSize 11
title Contratto DAG — Radice Consumatore vs Plugin Indipendente package "N3 — RADICE (deposito)" #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 : "versione plugin" ROOT -down→ PLUGIN : "publishToMavenLocal" @enduml
Le `codebase-gradle/build.gradle.kts`fa 6 righe. Nessun`src/`, nessun`buildSrc/`, nessun`gradle/rag-bench.gradle.kts`. Giusto `plugins { alias(libs.plugins.codebase) }`e i repository. Tutta la complessità vive in`codebase-plugin/`.
== Conclusion : La Duplicazione Apparente Che Fa Risparmiare Tempo
Quando mostro questa struttura a qualcuno, la prima reazione è spesso : « Ma hai due`gradlew`, due`settings.gradle.kts`, due`libs.versions.toml`— è una duplicazione!
Sì. E no.
La « duplicazione », è riprodurre la stessa informazione in due posti. Qui, sono due file *distinti* che servono due usi *distinti*: il catalogo del plugin (30+ dipendenze per builder) e il catalogo dalla radice (2-3 dipendenze da consumare). Il wrapper del plugin (versione bloccata per lo sviluppo) e il wrapper della radice (versione potenzialmente diversa, per l'esercizio del plugin).
Non è una duplicazione. È una **separazione delle** responsabilità** applicata al sistema di build. Ogni build fa una cosa, e solo una. La radice consuma. Il plugin si compila.
Il costo? Un ordine`publishToMavenLocal`tra la build del plugin e il build della radice. Il vantaggio? Una chiarezza architettonica che elimina ore di debugging a valle
Da quando ho distribuito questo pattern su`bakery-gradle`, `plantuml-gradle`, `codebase-gradle`, e gli altri plugin di`foundry/public/`, non ho mai più esitato nell'aprire un terminale in uno dei miei repository. Le primo riflesso —`./gradlew tasks`— cammina sempre, dà sempre i buoni compiti, e mi dice istantaneamente se tutto è sano.
Ed ecco l'architettura corretta. Non si legge in un README. Si verifica in un terminal, in meno di dieci secondi.
== Riferimenti
* xref:0105_integrer_graphify_workflow_gradle_post.adoc[Articolo 0105 — Integrare Graphify in un workflow Gradle]
* xref:0117_granularisation_oss_css_repositorie_workspace_post.adoc[Article 0117 — La Granularizzazione OSS/CSS]
* xref:0123_knowledge_graph_moteur_recommandation_articles_connexes_post.adoc[Article 0123 — Il Knowledge Graph come motore di raccomandazione]