La Arquitectura « Plugin Independiente + Raíz Consumidora » : ¿Por qué mis builds de Gradle se duplican?
Publié le 14 May 2026
- El Problema: Tres Arquitecturas Que Me Hicieron Perder el Tiempo
- La Solución: Dos Builds independientes, Una raíz consumidora
- ¿Por qué esta arquitectura me ha conquistado?
- La anatomía del subdirectorio `{name}-plugin/
- Lo Que No Se Hace
- Comparación : Las Tres Arquitecturas Frente al Dogfooding
- El Contrato DAG : Un build raíz nunca proviene desde un subdirectorio
- Conclusión : La duplicación aparente que ahorra tiempo
- Referencias
Estás clonando un repositorio de plugins de Gradle. Abres una terminal. ¿Qué escribes? ¿Entonces?./gradlew tasks, claro. ¿En qué carpeta? ¿La raíz? ¿El submódulo? ¿Hay que leer primero un README para saber cómo construir? Si dudan ni siquiera solo un segundo, la arquitectura del proyecto está rota
Así es como resolví este problema una vez por todas — y por qué esto pattern es hoy la firma de todos mis plugins Gradle en`foundry/public/`.
El Problema: Tres Arquitecturas Que Me Hicieron Perder el Tiempo
Antes de converger hacia el patrón actual, he vacilado entre tres enfoques clásicos para organizar un plugin Gradle. Cada uno tenía un defecto imperdonable.
Opción 1: El Plugin aislado (sin dogfooding)
El proyecto solo contiene el plugin. No hay ejemplo de consumo. No proyecto que lo ejerce. Para probarlo, hay que crear un proyecto externo, y referenciar el plugin mediante`mavenLocal`o un build compuesto, y solo allí verificar que funciona.
$ git clone mon-plugin
$ cd mon-plugin
$ ./gradlew build # le plugin compile
$ # ... et maintenant ? comment je l'essaie ?
|
Un plugin Gradle sin ejemplo de consumo, es una biblioteca sin pruebas de integración. Nunca sabes si la última modificación ha roto la experiencia del usuario. |
Opción 2: El Monorepo Clásico (include(":plugin"))
Gradle`init`genera`settings.gradle.kts`con`include("plugin")`. El root y el submódulo comparten el mismo daemon, las mismas configuraciones, los mismos catálogos. práctico, pero acoplado.
.
├── settings.gradle.kts → include("plugin")
├── build.gradle.kts → plugins { id("mon-plugin") }
├── plugin/
│ └── build.gradle.kts → java-gradle-plugin
└── gradle/
└── libs.versions.toml
Lo que se atasca:
-
La raízdebetener la misma versión de Gradle que el submódulo.
-
`libs.versions.toml`es compartido — versión catalogs comunes, dependencias
que huyen de un módulo a otro
-
Imposible compilar el plugin independientemente del root.
-
La CI debe compilar los dos módulos, aunque solo el plug‑in haya cambiado.
Opción 3 : el Build compuesto (includeBuild())
Separamos los dos en builds Gradle distintos y los relacionamos vía includeBuild("mon-plugin")`en`settings.gradle.kts.
Ya está mejor. La compilación del plugin está aislada. Pero el consumidor debe explícitamente referenciar la compilación externa — y la salida de `./gradlew tasks`en la raíz depende de la buena configuración del compuesto. El clonaje no es zero-config: hay que saber que el plugin está en una carpeta aparte, que el root lo referencia, etc.
.
├── settings.gradle.kts → includeBuild("plugin-build/")
├── build.gradle.kts → plugins { id("mon-plugin") }
└── plugin-build/
├── settings.gradle.kts
└── build.gradle.kts
Buscaba algo mejor. Mucho mejor.
La Solución: Dos Builds independientes, Una raíz consumidora
Aquí está el patrón que acabé adoptando :
.
├── 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
|
La clave:`{name}-plugin/`es un proyecto Gradle completo y autónomo. Tiene su propio envoltorio, su propio ajustes, su propio catálogo de versiones. Se clona, se construye, se prueba, y se publica sin necesidad de root estar al tanto de su existencia. |
El root, él, solo hace una cosa: aplicar el plugin.
plugins {
alias(libs.plugins.bakery)
}
repositories {
mavenLocal()
mavenCentral()
}
bakery { configPath = file("site.yml").absolutePath }
Tres líneas en el caso de`bakery-gradle`. Nada más. Cero`include(), cero`includeBuild(), cero subproyecto. Un build Gradle clásico que aplica un plugin como cualquier ¿qué consumidor lo haría.
El flujo de trabajo
Failed to generate image: PlantUML preprocessing failed: [From <input> (line 32) ]
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12
left to right direction
package "RAÍZ (Consumidor)" #CCFFCC {
usecase "Clone el repositorio" as Clone
usecase "./gradlew tareas" 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/ (Compilación Independiente)" #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 : "depende del plugin
publicado localmente"
^^^^^
Syntax Error? (Assumed diagram type: component)
@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultFontSize 12
left to right direction
package "RAÍZ (Consumidor)" #CCFFCC {
usecase "Clone el repositorio" as Clone
usecase "./gradlew tareas" 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/ (Compilación Independiente)" #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 : "depende del plugin
publicado localmente"
BuildPlugin -up-> MavenLocal
TestPlugin -up-> BuildPlugin
@enduml
El Workflow Concreto
# 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.
¿Por qué esta arquitectura me ha conquistado?
Tres beneficios que, combinados, valen el costo de la «duplicación» :
Dogfooding nativo, retroalimentación instantánea
La mejor prueba de un plugin Gradle es usarlo. No es una prueba unitaria simulada. No es un`GradleRunner`con un proyecto de prueba. Un verdadero build que aplica el plugin sobre archivos reales.
$ git clone bakery-gradle
$ cd bakery-gradle
$ ./gradlew bake # ← le plugin est exercé immédiatement
Si el plugin está roto, el build raíz lo dice. No es necesario ir buscar un proyecto de prueba externo. El dogfooding es la primera tarea que lanza un nuevo contribuidor. Es la prueba de humo definitiva.
|
La regla es simple: si la raíz compila y`./gradlew tasks` Muestra tus tareas del plugin, el plugin es funcional. Sin sorpresas en producción |
2. Clonación Zero-Config
Un `git clone && ./gradlew tasks`y el recién llegado ve todo caminar sin configurar nada. El`build.gradle.kts`raíz es la documentación viva de uso del plugin. El`site.yml`al lado muestra la configuración esperada.
Compárese con la alternativa: un README de tres párrafos que explica cómo crear el plugin y cómo crear el proyecto de test. Un nuevo colaborador lee el README en diagonal, se equivoca, abre un problema — aunque la información podría ser ejecutable
|
La documentación más robusta no es la que se lee. Es esa que unoejecuta. El build raíz es la documentación ejecutable del plugin. |
3. Builds aislados, CI independientes
El plugin tiene su propio wrapper Gradle, su propio ciclo de vida, sus propias pruebas. Puede:
-
Actualizar Gradle en el plugin sin tocar la raíz
-
Añadir una dependencia en el plugin sin que se filtre en el root
-
Romper el plugin sin afectar al build raíz (mientras usted no
No publiquéis la versión rota).
-
Tener una CI que build/test el plugin, y otra que ejerce el
root — independientemente
.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
La anatomía del subdirectorio `{name}-plugin/
Observemos de más cerca lo que vive en la carpeta del plugin:
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
Todo está ahí. No hay dispersión entre la raíz y el subdirectorio. El desarrollador que trabaja en el plugin nunca necesita salir`codebase-plugin/`. El desarrollador que usa el plugin solo mira la raíz — y el`build.gradle.kts`de 3 líneas le dice todo lo que necesita saber
El Catálogo de Versiones: Dos Archivos Distintos
|
|
|
Número de dependencias |
2-3 (plugin + readme opcionalmente) |
más de 30 (langchain4j, pgvector, cucumber…) |
rol |
Consumir el plugin |
Construye el plugin |
¿Quién lo lee? |
El usuario del plugin |
El desarrollador del plugin |
El root tiene un catálogo voluntariamente minimal. El plugin tiene un catálogo completo. La confusión es imposible: cada build tiene su propio scope de dependencias
|
Si ya has pasado una hora depurando una colisión de dependencias entre tu plugin y tu proyecto de prueba, comprendes el valor de esta separación. Los catálogos independientes eliminan este problema por construcción. |
Lo Que No Se Hace
Este patrón no es mágico. Impone una restricción que yo me inflige de buena gana :
root no construye el plugin. Debes`publishToMavenLocal` ou desplegar en un repositorio antes de que root pueda consumirlo.
Es un costo mínimo, y es la buena restricción. El root consume el plugin como un cliente externo — vía Maven. Exactamente como lo haría un proyecto de terceros. Si el plugin no es publicable, el root te lo dice de inmediato.
# La seule "friction" du pattern
$ cd codebase-plugin && ./gradlew publishToMavenLocal && cd ..
$ ./gradlew tasks --group=codebase
Comparación : Las Tres Arquitecturas Frente al Dogfooding
Plugin aislado |
Monorepo`include()` |
Compuesto`includeBuild()` |
Raíz + plugin independiente |
|
`git clone && gradlew tasks`da las tareas del plugin |
�❌ |
✅ |
</think> (The assistant returns no text, as there is no source content to translate.) |
✅ |
Build plugin independiente del root |
✅ |
❌ |
✅ |
�✅ |
Catálogo de versiones separadas |
✅ |
�❌ |
✅ |
✅ |
No`include()` ni |
✅ |
❌ |
�❌ |
</think> </think> |
Dogfood nativo sin configuración |
�❌ |
�✅ |
⚠️ |
✅ |
CI plugin independiente |
�✅ |
❌ |
✅ |
✅ |
El root es un ejemplo de consumo real |
�❌ |
⚠️ |
�✅ |
✅ |
La columna de la derecha marca todas las casillas. Es por eso que yo no Volveré más atrás
El Contrato DAG : Un build raíz nunca proviene desde un subdirectorio
Esta arquitectura se integra naturalmente en el DAG N0→N3 de mi workspace. El patrón es : el plugin N2 es el hub de sus propias dependencias, la raíz N3 es un terminal que aplica los hubs
Le codebase-gradle/build.gradle.kts`hace 6 líneas. No`src/, no`buildSrc/, no`gradle/rag-bench.gradle.kts. justo plugins { alias(libs.plugins.codebase) }`y los repositorios. Toda la complejidad vive en`codebase-plugin/.
Conclusión : La duplicación aparente que ahorra tiempo
Cuando muestro esta estructura a alguien, la primera reacción es a menudo : « Pero tienes dos`gradlew`, dos`settings.gradle.kts`, dos`libs.versions.toml`— ¡Es duplicado!
Sí. Y no.
La «duplicación», es reproducir la misma información en dos lugares. Aquí, son dos archivos distintos que sirven para dos usos distintos : el catálogo del plugin (30+ dependencias para builder) y el catálogo de la raíz (2-3 dependencias para consumir). El wrapper del plugin (versión bloqueada para el desarrollo) y el envoltorio de la raíz (versión potencialmente diferente, para el ejercicio del plugin).
No es duplicación. Es separación des responsabilidades aplicada al sistema de compilación. Cada compilación. Hace una cosa, y solo una. La raíz consume. El plugin se compila.
¿El costo? Un pedido`publishToMavenLocal`entre el build del plugin y la compilación de la raíz. ¿El beneficio? Una claridad arquitectónica que elimina horas de depuración aguas abajo.
Desde que he desplegado este patrón sobre`bakery-gradle`, plantuml-gradle, codebase-gradle, y los otros plugins de`foundry/public/, no tengo nunca más dudado al abrir una terminal en uno de mis repositorios. El primer reflejo—./gradlew tasks`— camina siempre, da siempre las buenas tareas, y me dice al instante si todo está sano.
Así es la buena arquitectura. No se lee en un README. Se experimenta en una terminal, en menos de diez segundos.