Large Java platforms often span dozens of repositories — each with its own build files, dependency declarations, and tooling configuration. Without a unified approach, version drift creeps in, build logic gets duplicated, and a routine dependency upgrade turns into a multi-repo coordination nightmare.
This post walks through an architecture pattern that solves this using Gradle: a dedicated shared-build repository that acts as the single source of truth for build logic, dependency versions, and project conventions across every repo in the platform.
1. Convention over configuration. Subprojects declare what type they are, not how to build. All build logic lives in one place.
2. Version catalogs as a menu. The catalog defines what's available and at what version. Your build.gradle decides what to use. Unused catalog entries cost you nothing.
3. Force-locking for distributable consistency. When dozens of repos contribute to a single artifact, resolutionStrategy.force is the only reliable way to guarantee one version of each dependency reaches production.
4. All version changes go through shared-build. No inline group:artifact:version in product repos. Every dependency change is reviewed in one place with full awareness of cross-repo impact.
5. Convention plugins are build-time only. They add zero bytes to output jars. Your jar contains only your classes and your declared implementation/api dependencies.
Imagine a platform split across 30+ repositories. Each repo builds Java artifacts that ultimately end up packaged together in a single distributable. The challenges are real:
- Different repos declare
jackson-databind:2.12andjackson-databind:2.14— which one ends up in production? - A new developer copies a
build.gradlefrom an old repo and misses a required compiler flag set two years ago. - A security patch for a logging library requires touching 30
build.gradlefiles across 30 repos.
The solution is to pull all shared build concerns into one place.
Create a dedicated repository — call it shared-build — that lives alongside all your product repos:
/your/git-projects/
├── shared-build ← shared build logic (required alongside every repo)
├── platform ← core platform repo
├── commons ← shared libraries repo
├── services ← services repo
├── app-itsm ← application repo
└── ... all other repos
Every product repo's settings.gradle pulls in the shared build logic at configuration time:
pluginManagement {
apply from: '../shared-build/buildSrcCommon/common-settings.gradle'
includeBuild '../shared-build/buildSrcCommon'
}shared-build is not shipped code. It never ends up in a production artifact. It is purely build infrastructure.
shared-build/
├── buildSrcCommon/ ← core: convention plugins + custom Gradle plugins
│ ├── src/main/groovy/ ← convention plugins (.gradle files) + utility classes
│ └── src/main/java/ ← Java-implemented Gradle plugins and tasks
├── gradle/ ← version catalogs (TOML) — dependency registry
│ ├── platform/ ← internal platform artifact aliases
│ ├── internal/ ← organization-internal artifacts
│ ├── thirdparty/ ← third-party artifacts
│ ├── npm/ ← npm package aliases
│ └── *-overrides/ ← environment-specific version overrides
├── scripts/ ← developer utility and migration scripts
└── docs/ ← build system documentation
Two things do the heavy lifting: convention plugins and version catalogs.
The core idea is convention over configuration. Rather than every build.gradle spelling out how to compile, test, publish, and sign an artifact, each subproject simply declares what type it is:
// A subproject build.gradle — this is all you need for a standard Java module
plugins {
id 'com.myorg.conventions.java-module'
}
description = "My Module"
dependencies {
implementation thirdpartyLibs.gson
implementation internalLibs.some.shared.lib
}The convention plugin com.myorg.conventions.java-module brings in everything else automatically: Java 21 compilation settings, standard test framework wiring, JaCoCo coverage, SBOM generation, artifact signing, publishing configuration, and more.
Each type maps to a specific base convention plugin:
| Convention | What it represents |
|---|---|
java-module |
Standard Java library with optional plugin XML |
container-module |
A container of subprojects — applied at repo root |
it-test-module |
Integration test project |
war-module |
WAR artifact |
plugins-module |
Plugin-only artifact (no Java code) |
bom-module |
Bill of Materials POM publishing |
Base conventions are composed from smaller, focused capability conventions:
| Convention | Capability |
|---|---|
java-library |
Java compilation, test setup, resolution strategy |
lombok |
Lombok annotation processing |
protobuf |
Protobuf code generation |
node |
Node.js/npm build |
publishing |
Maven repository publishing |
code-coverage |
JaCoCo coverage reporting |
code-signing |
GPG artifact signing for releases |
cyclonedx |
Software Bill of Materials (SBOM) generation |
reflections |
Reflections library index generation |
This composition means changing how all Java modules are compiled — say, upgrading to Java 21 — is a single-line change in java-library.gradle, and every module picks it up automatically.
A common misconception: applying a convention plugin does not bloat your jar. Convention plugins configure how Gradle builds your project. They run at build time and produce zero bytes in your output artifact.
Convention plugins live in shared-build/buildSrcCommon/src/main/groovy/ as .gradle files. The filename directly becomes the plugin ID — no registration required.
buildSrcCommon/src/main/groovy/
└── com.myorg.conventions.my-module.gradle → id 'com.myorg.conventions.my-module'
A minimal convention plugin:
// com.myorg.conventions.my-module.gradle
plugins {
id 'java-library'
id 'com.myorg.conventions.java-library' // compose from existing conventions
}
dependencies {
implementation platform(platformLibs.platform.bom)
testImplementation thirdpartyLibs.junit.jupiter.api
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}Key rules:
- Compose, don't duplicate — build on existing conventions via the
plugins {}block rather than copying their logic. - Use catalog accessors — reference dependencies via
thirdpartyLibs,internalLibs,platformLibsetc., never with inlinegroup:artifact:version. - Only shared defaults belong here — per-project configuration (description, specific dependencies) stays in the project's own
build.gradle. - Once the file is added to
shared-buildand merged, apply it in any repo'sbuild.gradlewithid 'com.myorg.conventions.my-module'— no other wiring needed.
Gradle's version catalogs provide a TOML-based registry of dependency aliases with pinned versions. Instead of scattering group:artifact:version strings across dozens of build.gradle files, all version decisions live in shared-build/gradle/.
gradle/
├── thirdparty/libs.versions.toml ← gson, guava, jackson, netty, ...
├── internal/libs.versions.toml ← organization-internal artifacts
├── platform/libs.versions.toml ← cross-repo platform artifacts
└── npm/libs.versions.toml ← npm packages
A typical entry in thirdparty/libs.versions.toml:
[versions]
gson = "2.13.1"
guava = "33.2.1-jre"
[libraries]
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }Usage in any repo's build.gradle:
dependencies {
implementation thirdpartyLibs.gson
implementation thirdpartyLibs.guava
}This is an important distinction. Declaring a dependency in the version catalog does not inject it into any project. The catalog is a menu of approved, versioned dependencies. Your build.gradle decides what to order from that menu. Only declared dependencies end up in your jar — everything else in the catalog is irrelevant to your build.
For internal artifacts built from the same set of repos, version numbers can be replaced with git commit SHAs:
[libraries]
platform-core = { module = "com.myorg:platform-core", version = "a3f8c21d..." }This means a "release" is defined as a snapshot of all component SHAs at a given point in time — making it trivially reproducible.
The version catalog tells Gradle what versions are available. But in a large multi-repo project, transitive dependencies can still introduce version conflicts. The solution is resolutionStrategy.force, applied globally via a shared plugin:
// SharedResolutionStrategyPlugin — applied to all configurations in all projects
configurations.all {
resolutionStrategy {
force thirdpartyLibs['gson'] // 2.13.1, everywhere
force thirdpartyLibs['guava']
force thirdpartyLibs['jackson-databind']
force thirdpartyLibs['slf4j-api']
// ... all conflict-prone deps
}
}Once a dependency is force-locked, no project can override it — a project-level version declaration is silently overridden by the force. This is intentional: it guarantees that the final distributable, assembled from dozens of repos, has exactly one version of each critical dependency.
Some components have legitimately different requirements. Rather than polluting the global resolution strategy, isolated override catalogs handle this:
gradle/
├── edge-overrides/libs.versions.toml ← edge deployment needs different netty
└── agent-overrides/libs.versions.toml ← agent component needs older commons-beanutils
The shared resolution plugin detects the project type and applies the relevant override catalog on top of the global forces.
This is worth being explicit about:
| Source | Ends up in jar? |
|---|---|
Your compiled classes (src/main/java) |
Yes |
Your implementation / api dependencies |
Yes |
| Generated metadata (e.g. reflections index) | Yes — small, a few KB |
Plugin XML files (src/main/plugins) |
Yes, if present |
| Convention plugin Gradle logic | No — build-time only |
testImplementation dependencies |
No — test classpath only |
| Unused version catalog entries | No — catalog is just a registry |
The convention plugins wire up tasks, configure the compiler, and enforce resolution strategies. None of that logic is compiled into your output. Your jar contains exactly what you declared in your dependencies {} block, plus your own classes.
Always reference via catalog alias — never hardcode group:artifact:version:
// Correct
implementation thirdpartyLibs.gson
implementation internalLibs.jena.core
// Not allowed — defeats the purpose of centralized version management
implementation 'com.google.code.gson:gson:2.13.1'If a library isn't in any catalog:
- Search all catalogs first to avoid duplicates:
grep -r "com.example:my-lib" shared-build/gradle/ - Add it to the appropriate TOML with an explicit version
- Raise a PR to
shared-build— reviewers check for transitive conflicts across all repos - Once merged, reference via catalog alias in your project
Update the version in the TOML file, raise a PR to shared-build. Because the change affects every repo that uses that dependency, a full platform build is expected as validation before merging.
This is the most constrained scenario. Because resolutionStrategy.force applies globally, a per-project version override is silently ignored.
Option 1 — Request a platform-wide version bump (preferred). If your use case justifies the upgrade and it doesn't break other repos, update shared-build and let everyone benefit.
Option 2 — Dependency shading (last resort). Use the Shadow plugin to relocate the library under a private package, bundling your own copy without conflicting with the platform version:
plugins {
id 'com.gradleup.shadow'
}
shadowJar {
relocate 'com.google.gson', 'com.myapp.internal.gson'
}This increases jar size and creates a hidden copy of the library — use only when a platform version bump is genuinely not possible.
shared-buildrepo cloned as a sibling directory- Gradle wrapper available (copy from any existing repo)
/your/git-projects/
├── shared-build ← already present
└── my-new-app ← your new repo
├── gradle/wrapper/
├── gradlew
├── gradlew.bat
├── gradle.properties
├── settings.gradle
├── build.gradle ← container-module only
├── app-core/
│ ├── src/main/java/
│ ├── src/test/java/
│ └── build.gradle
├── app-api/
│ ├── src/main/java/
│ └── build.gradle
└── app-test/
├── src/main/java/ ← *IT.java integration tests
└── build.gradle
group=com.myorg
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8pluginManagement {
apply from: '../shared-build/buildSrcCommon/common-settings.gradle'
includeBuild '../shared-build/buildSrcCommon'
}
rootProject.name = 'my-new-app'
include('app-core')
include('app-api')
include('app-test')
// Allow local cross-repo builds when needed
if (hasProperty('includeBuild')) {
if (file('../commons').exists()) includeBuild('../commons')
if (file('../platform').exists()) includeBuild('../platform')
}plugins {
id 'com.myorg.conventions.container-module'
}| Subproject contains | Convention |
|---|---|
| Java code (main use case) | java-module |
| Plugin XML files only | plugins-module |
Integration tests (*IT.java) |
it-test-module |
| Subprojects only, no code | container-module |
| WAR artifact | war-module |
Run the helper script to determine convention from directory structure:
find . -maxdepth 1 -type d -exec ../shared-build/scripts/setProjectType.pl {} \;plugins {
id 'com.myorg.conventions.java-module'
}
description = "Application Core"
dependencies {
implementation internalLibs.jena.core // RDF model API
implementation internalLibs.jena.arq // SPARQL engine
implementation project(':app-api') // same-repo dependency
implementation platformLibs.platform.core // cross-repo dependency
testImplementation thirdpartyLibs.mockito.core
}plugins {
id 'com.myorg.conventions.it-test-module'
}
description = "Application Integration Tests"
dependencies {
testImplementation project(':app-core')
testImplementation thirdpartyLibs.junit.jupiter.api
}# Build a single subproject
./gradlew :app-core:build
# Build the whole repo
./gradlew build
# Build using local sibling repos instead of published artifacts
./gradlew build -PincludeBuild
# Verify the correct convention was applied
./gradlew :app-core:validate-convention
# Identify unused / undeclared / misclassified dependencies
./gradlew :app-core:projectHealthprojectHealth is worth running after initial setup. It analyses compiled bytecode to report:
- Declared but unused dependencies to remove
- Used but undeclared transitive dependencies to add explicitly
- Dependencies that should be
apiinstead ofimplementation
| Script | Purpose |
|---|---|
setProjectType.pl |
Infers correct convention plugin from directory structure |
convertToVersionCatalog.pl |
Migrates Maven pom.xml dependencies to TOML catalog format |
runForEach.pl |
Runs a Gradle command across all local repos simultaneously |
onboard.groovy |
Registers a new repo with the shared build system |
usedUndeclaredDependencyFinder.sh |
Finds transitive dependencies being used without explicit declaration |
Short answer: you can't do this unilaterally.
Libraries listed in resolutionStrategy.force are version-locked across all configurations in all projects. If you declare a different version in your own build.gradle, it will be silently overridden back to the forced version. The force always wins.
// This has no effect if gson is force-locked
dependencies {
implementation 'com.google.code.gson:gson:2.10.0' // overridden to 2.13.1
}Your options:
| Option | When to use |
|---|---|
Request a version bump in shared-build |
Preferred — if the upgrade is safe for the whole platform, everyone benefits |
| Dependency shading (Shadow plugin) | Last resort — bundles a private copy under a relocated package, increases jar size |
To request a bump: update the version in the relevant TOML in shared-build, validate nothing breaks across repos, raise a PR for review.
No. Convention plugins are build-time Gradle logic — they configure tasks, set compiler flags, wire up resolution strategies. None of that gets compiled into your output jar.
Your jar contains only:
- Your compiled classes
- Dependencies you explicitly declared as
implementationorapi - A small amount of generated metadata (reflections index, plugin XML if applicable)
testImplementation dependencies never enter your jar. Unused version catalog entries have no effect on your build at all.
No. The catalog is a registry of available dependencies, not an injection list. Think of it as a menu — you only get what you order.
thirdparty/libs.versions.toml
├── gson 2.13.1 ← available
├── guava 33.x ← available
├── netty 4.x ← available
└── ... 600+ more ← available but ignored unless you declare them
Only what appears in your dependencies {} block gets resolved and packaged.
You cannot add group:artifact:version directly to your build.gradle. The rule is: all dependencies must come from a version catalog.
The process:
- Search all catalogs to avoid duplicates:
grep -r "com.example:my-lib" shared-build/gradle/ - Add the entry to the appropriate TOML:
[versions] my-lib = "1.2.3" [libraries] my-lib = { module = "com.example:my-lib", version.ref = "my-lib" }
- Raise a PR to
shared-build— the build team reviews for transitive conflict risk across all repos - Once merged, use it via catalog alias:
implementation thirdpartyLibs.my.lib
The PR review gate exists because adding an unchecked library at an unknown version can introduce transitive dependency conflicts that break other repos' builds.
Some libraries are maintained as internal forks — the organization patches them for security, performance, or compatibility reasons. These live in the internal catalog (internalLibs) rather than thirdpartyLibs.
Example: Apache Jena for RDF processing is available as an internally patched build:
// Use the internal fork — not the upstream Apache release
implementation internalLibs.jena.core // RDF model API
implementation internalLibs.jena.arq // SPARQL query engine
implementation internalLibs.jena.ontapi // Ontology API
implementation internalLibs.jena.base // Core abstractions
implementation internalLibs.jena.iri3986 // IRI parsingUsing the upstream org.apache.jena:jena-core:5.x.x directly is not permitted — it bypasses the internal fork and the version enforcement.
Internal artifacts built from sibling repos are tracked in the platform catalog (platformLibs), versioned by git commit SHA:
// Cross-repo dependency — resolved from Nexus in normal builds
implementation platformLibs.platform.core
implementation platformLibs.commons.utils
// When working locally across repos, use -PincludeBuild
// Gradle substitutes the Nexus artifact with your local build automatically
./gradlew build -PincludeBuildThe -PincludeBuild flag triggers composite builds — Gradle detects the sibling repo on disk and substitutes the Nexus artifact with the locally built version, so local changes in a dependency repo are immediately reflected without publishing.
Run the helper script against the subproject directory:
../shared-build/scripts/setProjectType.pl ./my-subprojectIt analyses the directory structure (presence of src/main/java, src/test/java, src/main/plugins, etc.) and recommends the appropriate convention. You can also run it with -u to auto-update the build.gradle:
../shared-build/scripts/setProjectType.pl ./my-subproject -uAfter wiring up the convention, validate it matches what the build system expects:
./gradlew :my-subproject:validate-conventionShort answer: no — it's silently overridden, not rejected.
There are three layers of enforcement, each with different scope and behaviour:
Layer 1 — resolutionStrategy.force (always active, silent)
This is the primary mechanism. If you write a hardcoded version and that dependency is force-locked, Gradle silently substitutes your version with the forced one. No error, no warning — the build succeeds, but your version is ignored.
// You write this:
implementation 'com.google.code.gson:gson:2.10.0'
// Gradle resolves this:
// → gson:2.13.1 (force overrides silently)This is the most dangerous scenario — you think you're using 2.10.0 but you're actually getting 2.13.1 at runtime with no indication.
Layer 2 — Automated task checks (targeted, hard failures)
| Task | What it checks | Blocks build? |
|---|---|---|
validate-convention |
Correct convention plugin type applied | Yes — GradleException |
ValidateBannedDependenciesTask |
Specific banned artifacts in WAR classpath | Yes — GradleException |
projectHealth / buildHealth |
Unused, undeclared, misclassified deps | No — advisory only |
None of these tasks scan build.gradle source for inline group:artifact:version strings. validate-convention only checks the plugin type, not the dependency declarations. ValidateBannedDependenciesTask is wired to specific configurations in specific projects and checks a fixed list of known-banned artifacts.
Layer 3 — Code review (manual, cultural gate)
The real enforcement of the "no hardcoded versions" rule is the PR review process. Reviewers catch inline version declarations before they merge. This is the only layer that reliably flags the pattern itself rather than its consequences.
Summary
| Mechanism | Automated? | Blocks build? | Catches hardcoded versions? |
|---|---|---|---|
resolutionStrategy.force |
Yes | No — silent override | Irrelevant — ignores your version |
validate-convention |
Yes (CI) | Yes | No — checks plugin type only |
ValidateBannedDependenciesTask |
Yes (narrow) | Yes | No — checks specific banned artifacts |
projectHealth |
Manual | No — advisory | Partially |
| PR code review | Manual | Yes (human gate) | Yes |
The practical risk of a hardcoded version is not a build failure — it is version confusion: you declare one version, a different version is silently used at runtime, and debugging the discrepancy later is non-trivial.