L'Architettura « Plugin Indipendente + Radice Consumatore »: Perché i miei Build Gradle Si Raddoppiano
Pubblicato il 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\nJBake può essere integrato nelle build Gradle utilizzando il plugin Gradle di JBake o chiamando direttamente l'interfaccia a riga di comando di JBake:\n\n----\n\ntasks.register<JavaExec>("bake") { mainClass.set("org.jbake.launcher.Main") classpath = configurations["jbake"] args = listOf(projectDir.absolutePath, "$buildDir/jbake") } ```\n\n./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/ (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
[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 "`./gradlew tasks`" as Tasks
usecase "`./gradlew bake`" as Dogfood
note bottom of Dogfood
Esegui il plugin
Feedback immediato
Zero 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
Ciclo di vita isolato
Unit test + Cucumber
CI dedicata possibile
end note
}
Clone -down-> Tasks
Tasks -down-> Dogfood
Dogfood ..> MavenLocal : "dipende dal plugin\npubblicato in locale"
BuildPlugin -up-> MavenLocal
TestPlugin -up-> BuildPlugin
@enduml
----
* Avere una CI che build/test il plugin, e un'altra che esercita il
root — indipendentemente
[source,text]
----
.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
----
== 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> |�✅ |✅ |Plugin di build indipendente dalla radice | |❌ |�✅ | |catalogo versioni separato |�✅ |�❌ | |</think> |nessun`include()` ni `includeBuild()` |✅ |❌ |�❌ |�✅ |Dogfood nativo senza configurazione |�❌ |�✅ |⚠️ | |plug-in CI indipendente |�✅ |�❌ |�✅ |</think> ✅ |Il root è un esempio di consumo reale |�❌ | |�✅ |
|===
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]
== Confronto: Le Tre Architetture di fronte al Dogfooding
[cols="2,2,2,2,2"]
|===
| |Plugin isolato |Monorepo`include()` |Composito`includeBuild()` |**Radice + plugin indep.** |`git clone && gradlew tasks`fornisce i task del plugin |❌ |D'accordo. |Pronto per tradurre. |Ready. |Build plugin indipendente dal root |Pronto per tradurre. |❌ |Pronto. |✅ |Catalogo versioni separato |Pronto. |❌ |Pronto per tradurre. |Pronto. |Nessun`include()` ni `includeBuild()` |Pronto. |❌ |❌ |✅ |Dogfood nativo senza config |❌ |✅ |⚠️ |Ready. |Plugin CI indipendente |Ready. |❌ |Ready. |Pronto. |Il root è un esempio di consumo reale |❌ |⚠️ |Pronto. |Ready.
|===
La colonna di destra spunta tutte le caselle. Ecco perché non non tornerò più indietro.
== Il Contratto DAG: Un Build Racin Non Viene Mai Eseguito Da una Sottocartella
Questa architettura si integra naturalmente nel DAG `N0→N3 del mio workspace. Il pattern è: *il plugin N2 è l'hub dalle sue proprie dipendenze, la radice N3 è un terminale che applica degli 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 = ... }
Nessuna logica di business
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
Test unitari + Cucumber
CI dedicata
end note
}
ROOT_TOML --> ROOT : "versione plugin"
ROOT -down-> PLUGIN : "publishToMavenLocal"
@enduml
----
Le `codebase-gradle/build.gradle.kts`sono 6 righe. Nessun`src/`, nessuno`buildSrc/`, nessun`gradle/rag-bench.gradle.kts`. Solo `plugins { alias(libs.plugins.codebase) }`e i repository. Tutta la complessità risiede in`codebase-plugin/`Please provide the French text you would like me to translate.
== Conclusione: La Duplicazione Apparente Che Fa Risparmiare Tempo
Quando mostro questa struttura a qualcuno, la prima reazione è spesso: « Ma ne hai due`gradlew`, due`settings.gradle.kts`, due`libs.versions.toml`— è una duplicazione!
Sì. E no.
La « duplicazione » consiste nel riprodurre la stessa informazione in due punti. Qui, in questo caso, sono due file *distinti* che servono a due scopi *distinti*: il catalogo del plugin (30+ dipendenze per builder) e il catalogo dalla radice (2-3 dipendenze per consumare). Il wrapper del plugin (versione bloccata per lo sviluppo) e il wrapper della radice (versione potenzialmente differente, per l'esercizio del plugin).
Non è duplicazione. È **separazione delle responsabilità** applicata al sistema di build. Ogni build fa' una cosa, e una sola. La radice consuma. Il plugin si builda.
Il costo? Un ordine`publishToMavenLocal`tra il build del plugin e il build della radice. Il vantaggio? Una chiarezza architettonica che elimina ore di debugging a valle.
Da quando ho implementato questo pattern su`bakery-gradle`, `plantuml-gradle`, `codebase-gradle`, e gli altri plugin di`foundry/public/`, non ho non più esitato aprendo un terminale in uno dei miei repository. Il primo riflesso —`./gradlew tasks`— funziona sempre, dà sempre i compiti giusti, e mi dice istantaneamente se tutto è sano.
Questa è la giusta architettura. Non si legge in un README. Si prova in un terminale, 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[Articolo 0117 — La Granularizzazione OSS/CSS]
* xref:0123_knowledge_graph_moteur_recommandation_articles_connexes_post.adoc[Articolo 0123 — Il Knowledge Graph come motore di raccomandazione]