Skip to content
My Guidelines
Guides
In this guide (64 items)

Gradle Build & Quality

Gradle — Build Configuration & Quality Plugins

MANDATORY FOR ALL AI AGENTS: When modifying build files, follow the patterns in this guide. Version bumps require explicit approval (BOM, plugins, JVM). The multi-module structure enforces hexagonal architecture boundaries — do NOT add cross-layer dependencies. Every new module MUST follow the dependency rules in §3.

Project-agnostic: code samples use placeholder package com.example.platform / service platform-service. Replace with your project’s actual values.

Supports Java-first and Kotlin-first projects. Sections tagged [Java], [Kotlin], or [Both] per applicability.

Companion guides:


Table of Contents

  1. Versions & Policy
  2. Compiler Configuration
  3. Multi-Module Structure
  4. Plugins
  5. Common Dependencies
  6. Module Conventions & Naming
  7. SDK / Library Publishing
  8. BOM Management
  9. Repository Strategy
  10. Resolution Strategy
  11. Custom Tasks
  12. gradle.properties Convention
  13. Pre-Merge Checklist

1. Versions & Policy

1.1 Pinned baseline [Both]

Tool Version Notes
Java (toolchain) 21 LTS Use Gradle toolchain — never rely on JAVA_HOME. Project will auto-download.
Gradle wrapper 9.0.x 8.13 acceptable transitionally; bump on next milestone.
Kotlin 2.2.x Optional. Only for Kotlin-source modules.
Spring Boot 4.0.x BOM-managed.
Spring Cloud 2025.x Aligns with Boot 4.0.
Spring Dependency Management 1.1.7 BOM importer plugin.

1.2 Version-bump policy

Change Approval Trigger
Patch bump (x.y.Z) Self-merge if green Security patches, bug fixes
Minor bump (x.Y.z) PR review Feature uptake
Major bump (X.y.z) of: Java toolchain, Gradle wrapper, Spring Boot, Kotlin ADR required Breaking changes likely
Plugin major bump PR review + smoke test on all modules
Adding new dep with transitive CVE risk PR + dep scan

1.3 Avoid

  • LATEST, +, SNAPSHOT in production builds. CI rejects on main.
  • Multiple Java versions across sibling modules — keeps the toolchain pinned at root.
  • Gradle version drift between SDK consumers and producers without coordination.

2. Compiler Configuration

2.1 Java [Java]

Toolchain pin (root build.gradle.kts or per-module):

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}

Compile options — always enable -parameters so Spring can read constructor / record parameter names without reflection hacks:

tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.add("-parameters")
options.encoding = "UTF-8"
options.release.set(21)
}

Lombok + annotation processors — common pattern in Java multi-module:

configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
dependencies {
compileOnly("org.projectlombok:lombok") // version via Boot BOM
annotationProcessor("org.projectlombok:lombok")
implementation("org.mapstruct:mapstruct:${property("mapstruct.version")}")
annotationProcessor("org.mapstruct:mapstruct-processor:${property("mapstruct.version")}")
}

Lombok in domain is acceptable for @Getter/@Setter/@Builder on value objects but discouraged for behaviour-rich aggregates. See java-code-style.md.

2.2 Kotlin [Kotlin]

Per-subproject, applied to modules with kotlin("jvm"):

tasks.withType<KotlinJvmCompile>().configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-Xjsr305=strict", // strict null-safety for Java interop
"-Xcontext-parameters" // Spring annotation support
)
allWarningsAsErrors.set(true)
}
}

Key flags:

  • -Xjsr305=strict — enforces null-safety when calling Java code annotated with @Nullable/@NonNull.
  • -Xcontext-parameters — Kotlin context parameters; required by some Spring patterns.
  • allWarningsAsErrors = true — every warning fails CI.

2.3 Mixed Java + Kotlin [Both]

When both languages coexist:

  • Place Java source in src/main/java/, Kotlin in src/main/kotlin/.
  • Apply both id("java") and kotlin("jvm") plugins.
  • Run Spotless Java + Kotlin sections (see §4.1).
  • Apply Detekt only to Kotlin subprojects (plugins.withId("org.jetbrains.kotlin.jvm")).

2.4 Javadoc options [Java] — SDK / library only

tasks.withType<Javadoc>().configureEach {
(options as StandardJavadocDocletOptions).apply {
addStringOption("Xdoclint:all", "-quiet")
encoding = "UTF-8"
}
}
java {
withJavadocJar()
withSourcesJar()
}

Microservices don’t ship Javadoc; SDKs do.


3. Multi-Module Structure

3.1 Canonical layout

platform-service/
├── domain/ # pure logic, no framework
├── application/ # use cases + ports
├── infrastructure/
│ ├── entry-points/ # inbound adapters
│ │ ├── rest-controller/
│ │ ├── scheduler-task/
│ │ ├── <topic>-consumer/ # e.g. order-consumer, payment-consumer
│ │ └── graphql-controller/
│ └── driven-adapters/ # outbound adapters
│ ├── jdbc-repository/
│ ├── redis-cache/
│ ├── metrics-publisher/
│ └── <provider>-adapter/ # e.g. stripe-adapter, paypal-adapter
├── bootstrap/ # Spring Boot wiring, runs the app
├── build.gradle.kts # root build
├── settings.gradle.kts # module includes
└── gradle.properties # versions

3.2 settings.gradle.kts

rootProject.name = "platform-service"
include(":domain")
project(":domain").projectDir = file("./domain")
include(":application")
project(":application").projectDir = file("./application")
include(":bootstrap")
project(":bootstrap").projectDir = file("./bootstrap")
// Entry points
include(":rest-controller")
project(":rest-controller").projectDir = file("./infrastructure/entry-points/rest-controller")
include(":scheduler-task")
project(":scheduler-task").projectDir = file("./infrastructure/entry-points/scheduler-task")
include(":order-consumer")
project(":order-consumer").projectDir = file("./infrastructure/entry-points/order-consumer")
// Driven adapters
include(":jdbc-repository")
project(":jdbc-repository").projectDir = file("./infrastructure/driven-adapters/jdbc-repository")
include(":stripe-adapter")
project(":stripe-adapter").projectDir = file("./infrastructure/driven-adapters/stripe-adapter")

3.3 Dependency matrix

Module Depends on Purpose
:domain (none) Aggregates, value objects, domain events, enums
:application :domain Use cases, inbound + outbound ports, commands
:rest-controller :application, :domain HTTP entry point
:<topic>-consumer :application, :domain Broker entry point (Kafka/RabbitMQ/SQS)
:scheduler-task :application, :domain Scheduled jobs
:jdbc-repository :application, :domain DB persistence (JDBC + Liquibase)
:redis-cache :application, :domain Cache adapter
:metrics-publisher :application, :domain Observability decorator
:<provider>-adapter :application, :domain Third-party integration (per provider)
:bootstrap ALL modules Spring Boot wiring + runtime

Hard rules — enforced by ArchUnit (testing.md §3.3):

  • :domain MUST have zero module deps.
  • :application MUST depend only on :domain.
  • Infrastructure modules depend only on :application + :domain.
  • Only :bootstrap applies org.springframework.boot plugin and produces a BootJar.

3.4 BootJar / JAR pattern

In every module except :bootstrap:

tasks.getByName<Jar>("bootJar") {
enabled = false
}

In :bootstrap only:

tasks.getByName<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
archiveFileName.set("${project.parent?.name}.${archiveExtension.get()}")
enabled = true
}
tasks.getByName<Jar>("jar") {
enabled = false
}

Why: BootJar bundles everything for runtime; the rest produce slim JARs for compile-time module composition.

3.5 Root vs subprojects pattern

Root build.gradle.kts applies common config to all subprojects:

plugins {
java
id("org.springframework.boot") version "4.0.2" apply false
id("io.spring.dependency-management") version "1.1.7"
id("com.diffplug.spotless") version "8.1.0"
id("jacoco")
id("org.sonarqube") version "7.1.0.6387"
}
allprojects {
group = "com.example.platform"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
// see §9 for GitHub Packages
}
}
subprojects {
apply(plugin = "java")
// NOTE: do NOT apply org.springframework.boot here — only :bootstrap applies it (§3.3) and produces the BootJar.
// subprojects inherit the BOM via io.spring.dependency-management only.
apply(plugin = "io.spring.dependency-management")
apply(plugin = "com.diffplug.spotless")
apply(plugin = "jacoco")
apply(plugin = "org.sonarqube")
java.sourceCompatibility = JavaVersion.VERSION_21
java.targetCompatibility = JavaVersion.VERSION_21
dependencyManagement {
imports {
mavenBom("org.springframework.boot:spring-boot-dependencies:${property("spring.boot.version")}")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("spring.cloud.version")}")
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
finalizedBy("jacocoTestReport")
testLogging { events("passed", "skipped", "failed") }
}
// bootJar/jar toggling lives in :bootstrap (the only module with the boot plugin), not here.
}
// Only :bootstrap applies the Spring Boot plugin and produces the executable BootJar (§3.3).
project(":bootstrap") {
apply(plugin = "org.springframework.boot")
}

4. Plugins

4.1 Spotless (formatting) [Both]

Plugin: id("com.diffplug.spotless") version "8.1.0". Apply via subprojects {}.

Java config:

spotless {
java {
googleJavaFormat("1.17.0")
target("src/**/*.java")
trimTrailingWhitespace()
endWithNewline()
removeUnusedImports()
}
}

Kotlin config:

spotless {
kotlin {
target("**/*.kt")
ktlint()
trimTrailingWhitespace()
endWithNewline()
}
kotlinGradle { ktlint() }
}

Mixed Java + Kotlin — combine both blocks.

Usage:

Terminal window
./gradlew spotlessCheck # CI gate
./gradlew spotlessApply # auto-fix

A PostToolUse hook (~/.claude/hooks/claude-spotless-apply.sh) runs spotlessApply after every source edit. See spotless skill.

4.2 Detekt (Kotlin static analysis) [Kotlin]

Plugin: id("io.gitlab.arturbosch.detekt") version "1.23.7". Apply ONLY to Kotlin subprojects.

plugins {
id("io.gitlab.arturbosch.detekt") version "1.23.7" apply false
}
subprojects {
plugins.withId("org.jetbrains.kotlin.jvm") {
apply(plugin = "io.gitlab.arturbosch.detekt")
configure<io.gitlab.arturbosch.detekt.extensions.DetektExtension> {
toolVersion = "1.23.7"
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
baseline = file("$rootDir/config/detekt/baseline.xml")
buildUponDefaultConfig = true
allRules = false
autoCorrect = false
parallel = true
ignoreFailures = false
}
dependencies {
"detektPlugins"("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.7")
}
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
jvmTarget = "21"
reports {
html.required.set(true)
xml.required.set(true)
sarif.required.set(true)
}
exclude("**/build/**", "**/generated/**", "**/resources/**")
}
tasks.named("check") { dependsOn("detekt") }
}
}

Starter config/detekt/detekt.yml:

build:
maxIssues: 0
weights: { complexity: 2, LongParameterList: 1, style: 1 }
complexity:
active: true
LongMethod: { threshold: 10 } # aligns with rules/coding-style.md
LongParameterList: { functionThreshold: 3, constructorThreshold: 5 }
CyclomaticComplexMethod: { threshold: 8 }
TooManyFunctions: { thresholdInClasses: 11, thresholdInObjects: 11 }
style:
active: true
MagicNumber: { ignoreNumbers: ['-1', '0', '1', '2'] }
MaxLineLength: { maxLineLength: 120 }
ReturnCount: { max: 2 }
WildcardImport: { active: true }
formatting:
active: true
android: false
autoCorrect: false

Usage:

Terminal window
./gradlew detekt
./gradlew :module:detekt
./gradlew detektBaseline # legacy code intake
./gradlew detektGenerateConfig # full default config to customize

4.3 JaCoCo (coverage) [Both]

Plugin: id("jacoco"). Apply via subprojects {}.

Per-module:

val jacocoExclusions = listOf("**/config/**", "**/*Application*")
fun org.gradle.testing.jacoco.tasks.JacocoReportBase.applyExclusions() {
classDirectories.setFrom(
files(classDirectories.files.map { fileTree(it) { exclude(jacocoExclusions) } })
)
}
tasks.named<JacocoReport>("jacocoTestReport") {
dependsOn(tasks.named("test"))
applyExclusions()
reports {
xml.required = true
html.required = true
}
}
tasks.named<JacocoCoverageVerification>("jacocoTestCoverageVerification") {
dependsOn(tasks.named("test"))
applyExclusions()
violationRules {
rule {
limit {
counter = "LINE"
minimum = "0.80".toBigDecimal() // 0.90 for SDKs / shared libs
}
}
}
}
tasks.named("check") {
dependsOn(tasks.named("jacocoTestCoverageVerification"))
}

Aggregated (root build.gradle.kts) — single XML covering all modules, consumed by Sonar:

tasks.register<JacocoReport>("codeCoverageReport") {
description = "Generates aggregated coverage report from all subprojects."
group = JavaBasePlugin.VERIFICATION_GROUP
dependsOn(subprojects.mapNotNull { it.tasks.findByName("test")?.let { _ -> it.tasks.named("test") } })
val excludedPatterns = listOf("**/config/**", "**/*Application*")
val allClassDirs = files()
val allSourceDirs = files()
val allExecData = files()
subprojects {
plugins.withType<JacocoPlugin>().configureEach {
tasks.withType<Test>().configureEach {
val jacocoExt = extensions.findByType<JacocoTaskExtension>()
jacocoExt?.destinationFile?.let { allExecData.from(it) }
}
val sourceSets = extensions.getByType<SourceSetContainer>()
val mainSourceSet = sourceSets.findByName("main") ?: return@configureEach
allSourceDirs.from(mainSourceSet.allSource.srcDirs)
allClassDirs.from(
mainSourceSet.output.classesDirs.asFileTree.matching { exclude(excludedPatterns) }
)
}
}
classDirectories.setFrom(allClassDirs)
sourceDirectories.setFrom(allSourceDirs)
executionData.setFrom(allExecData)
reports { xml.required = true; html.required = true }
}

Coverage targets (see testing.md):

  • Domain + application: ≥90% line coverage.
  • Adapters: ≥80%.
  • SDKs / shared libraries: ≥90% with jacocoTestCoverageVerification gating check.

A custom jacocoLogCodeCoverage task that prints a per-module table is documented in §11.

4.4 SonarQube (static analysis) [Both]

Plugin: id("org.sonarqube") version "7.1.0.6387". Apply at root only.

sonar {
properties {
property("sonar.projectKey", "example_platform-service")
property("sonar.organization", "example")
property("sonar.host.url", "https://sonarcloud.io")
property("sonar.token", System.getenv("SONAR_TOKEN") ?: "")
property("sonar.java.coveragePlugin", "jacoco")
property("sonar.gradle.skipCompile", "true")
property("sonar.kotlin.file.suffixes", ".kt")
property(
"sonar.coverage.jacoco.xmlReportPaths",
subprojects.joinToString(",") {
"$projectDir/${it.name}/build/reports/jacoco/test/jacocoTestReport.xml"
}
)
property("sonar.exclusions", "**/src/test/**,**/*Test.java,**/*IT.java")
property("sonar.coverage.exclusions", "**/config/**,**/*Application*")
property("sonar.cpd.exclusions", "**/*Config*")
}
}

Usage:

Terminal window
./gradlew sonar -Dsonar.token=$SONAR_TOKEN

CI: store SONAR_TOKEN in secret backend; never commit.

4.5 PlantUML (diagrams) [Both]

Plugin: id("io.gitlab.plunts.plantuml") version "2.3.0". Apply per subproject.

classDiagrams {
diagram {
name("${project.name} Class Diagram")
include(packages().withName("com.example.platform"))
writeTo(file("$rootDir/architecture/diagrams/${project.name}-class-diagram.puml"))
renderTo(file("$rootDir/architecture/diagrams/${project.name}-class-diagram.svg"))
}
packageDiagram {
name("${project.name} Packages Diagram")
include(packages().withName("com.example.platform"))
writeTo(file("$rootDir/architecture/diagrams/${project.name}-packages-diagram.puml"))
renderTo(file("$rootDir/architecture/diagrams/${project.name}-packages-diagram.svg"))
}
}

Output: architecture/diagrams/{module}-{class|packages}-diagram.{puml|svg}.

4.6 Structurizr C4 export [Both] — optional

For board-grade architecture docs (see arch-doc-spec.md):

val structurizr: Configuration by configurations.creating {
isCanBeResolved = true
isCanBeConsumed = false
}
dependencies {
structurizr("com.structurizr:structurizr-dsl:3.1.0")
structurizr("com.structurizr:structurizr-export:3.1.0")
}
val structurizrWorkspace = "$rootDir/architecture/structurizr/workspace.dsl"
val structurizrOutputDir = "$rootDir/architecture/structurizr/export"
val structurizrSrc = "$rootDir/architecture/structurizr/src"
val compileStructurizrExporter by tasks.registering(JavaCompile::class) {
source = fileTree(structurizrSrc)
classpath = structurizr
destinationDirectory.set(layout.buildDirectory.dir("structurizr-classes"))
sourceCompatibility = "21"
targetCompatibility = "21"
}
tasks.register<JavaExec>("structurizrExport") {
description = "Exports Structurizr C4 diagrams to PlantUML and Mermaid"
group = "documentation"
dependsOn(compileStructurizrExporter)
classpath = files(compileStructurizrExporter.get().destinationDirectory) + structurizr
mainClass.set("structurizr.StructurizrExporter")
args = listOf(structurizrWorkspace, structurizrOutputDir)
}

5. Common Dependencies

5.1 Java stack [Java] — inherited via subprojects {}

dependencies {
// Logging
implementation("org.slf4j:slf4j-api")
implementation("ch.qos.logback:logback-classic")
// JSON (Jackson 3.x — note `tools.jackson` group)
implementation("tools.jackson.core:jackson-databind")
// Code-gen
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
implementation("org.mapstruct:mapstruct:${property("mapstruct.version")}")
annotationProcessor("org.mapstruct:mapstruct-processor:${property("mapstruct.version")}")
// Testing
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.mockito:mockito-junit-jupiter")
testImplementation("org.junit.jupiter:junit-jupiter-engine")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// Architecture testing
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
}

5.2 Kotlin stack [Kotlin]

dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
// version managed by the Boot BOM; avoid hardcoding (move to gradle/libs.versions.toml if an override is needed)
implementation(platform("tools.jackson:jackson-bom"))
implementation("tools.jackson.core:jackson-databind")
implementation("org.slf4j:slf4j-api:${property("slf4j.version")}")
testImplementation(kotlin("test"))
testImplementation("io.mockk:mockk:${property("mockk.version")}")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

5.3 Bootstrap-only [Both]

These belong in :bootstrap because they need Spring Boot autoconfiguration:

dependencies {
implementation(project(":application"))
implementation(project(":domain"))
// include every adapter module
implementation(project(":rest-controller"))
implementation(project(":jdbc-repository"))
// ...
// Spring Boot core
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-autoconfigure")
implementation("org.springframework.boot:spring-boot-configuration-processor")
// Messaging — RabbitMQ
implementation("org.springframework.boot:spring-boot-starter-amqp")
// Messaging — Kafka
// implementation("org.springframework.kafka:spring-kafka")
// Cloud config
implementation("io.awspring.cloud:spring-cloud-aws-starter-parameter-store:4.0.0")
// Observability — see observability.md
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.micrometer:micrometer-registry-otlp")
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
// Dev only — not packaged in production image
developmentOnly("org.springframework.boot:spring-boot-devtools")
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
// Integration testing
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:junit-jupiter:1.20.4")
testImplementation("org.testcontainers:postgresql:1.20.4") // OR mssqlserver
testImplementation("org.testcontainers:rabbitmq:1.20.4") // OR kafka
testImplementation("org.wiremock.integrations:wiremock-spring-boot:3.6.0")
testImplementation("io.karatelabs:karate-junit5:1.5.0")
}

6. Module Conventions & Naming

6.1 Core modules

Module Convention
:domain Singular. Pure logic. No @Spring* annotations.
:application Use cases + ports. No framework imports.
:bootstrap One per service. Contains MainApplication, config/, application.yml.

6.2 Entry points (inbound adapters)

Naming: <protocol>-<role> or <resource>-<role>.

Pattern Example When
rest-controller rest-controller HTTP API
graphql-controller graphql-controller GraphQL API
<topic>-consumer order-consumer, payment-consumer One per major topic family — keeps consumer groups isolated
scheduler-task scheduler-task Cron/scheduled work
webhook-receiver stripe-webhook-receiver External callbacks

6.3 Driven adapters (outbound)

Pattern Example When
jdbc-repository jdbc-repository DB persistence (one module total)
redis-cache redis-cache Cache layer
metrics-publisher metrics-publisher Observability decorator
<topic>-producer event-producer Outbound broker
<provider>-adapter stripe-adapter, paypal-adapter, twilio-adapter Third-party SDK integration — one module per provider

Provider-adapter pattern: when integrating with N external providers for the same domain capability (payments via Stripe + PayPal + a mock for tests), give each its own module:

  • Each implements the same outbound port (PaymentProviderPort).
  • Selection happens in :bootstrap via @Profile or @ConditionalOnProperty.
  • One adapter per module keeps SDK upgrades isolated (Stripe SDK bump doesn’t recompile PayPal code).

6.4 Per-module build.gradle.kts minimal templates

:domain — no deps (parent applies java + lombok):

tasks.getByName<Jar>("bootJar") { enabled = false }

:application:

dependencies {
implementation(project(":domain"))
}
tasks.getByName<Jar>("bootJar") { enabled = false }

:jdbc-repository:

dependencies {
implementation(project(":application"))
implementation(project(":domain"))
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.liquibase:liquibase-core")
runtimeOnly("org.postgresql:postgresql") // OR mssql-jdbc
testImplementation("org.testcontainers:postgresql:1.20.4")
}
tasks.getByName<Jar>("bootJar") { enabled = false }

:<provider>-adapter — one per third-party:

dependencies {
implementation(project(":application"))
implementation(project(":domain"))
implementation("com.stripe:stripe-java:25.0.0") // pin per provider
testImplementation("org.wiremock.integrations:wiremock-spring-boot:3.6.0")
}
tasks.getByName<Jar>("bootJar") { enabled = false }

7. SDK / Library Publishing

When the project ships a reusable JAR (e.g., a shared event-messaging SDK consumed by many services):

7.1 Plugin block

plugins {
id("java")
id("maven-publish")
id("jacoco")
id("com.diffplug.spotless") version "8.1.0"
id("org.sonarqube") version "7.1.0.6387"
}
group = "com.example.platform"
version = "1.0.0"

7.2 compileOnly for framework deps

SDK MUST NOT impose specific Spring versions on consumers. Mark framework deps compileOnly:

dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.0.2"))
implementation("org.slf4j:slf4j-api")
implementation("org.springframework.boot:spring-boot-autoconfigure")
// Consumer brings these — SDK only compiles against them
compileOnly("org.springframework:spring-jdbc")
compileOnly("org.springframework:spring-tx")
compileOnly("org.springframework.amqp:spring-rabbit")
compileOnly("tools.jackson.core:jackson-databind")
compileOnly("org.projectlombok:lombok:1.18.38")
annotationProcessor("org.projectlombok:lombok:1.18.38")
// Genuinely owned by the SDK
implementation("net.javacrumbs.shedlock:shedlock-spring:7.2.0")
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework:spring-jdbc")
testImplementation("org.springframework:spring-tx")
testImplementation("org.springframework.amqp:spring-rabbit")
testImplementation("tools.jackson.core:jackson-databind")
testRuntimeOnly("com.h2database:h2")
}

7.3 Source + Javadoc JARs

java {
toolchain { languageVersion.set(JavaLanguageVersion.of(21)) }
withJavadocJar()
withSourcesJar()
}

7.4 Coverage gate

SDK gates check on JaCoCo verification (≥90%):

tasks.named<JacocoCoverageVerification>("jacocoTestCoverageVerification") {
dependsOn(tasks.test)
violationRules {
rule { limit { counter = "LINE"; minimum = "0.90".toBigDecimal() } }
}
}
tasks.named("check") {
dependsOn(tasks.named("jacocoTestCoverageVerification"))
}

7.5 Publishing block

publishing {
repositories {
maven {
name = "GitHubPackages"
url = uri("${project.findProperty("github_packages_url")}/your-sdk-name")
credentials {
username = System.getenv("GH_PACKAGES_USERNAME")
password = System.getenv("GH_PACKAGES_TOKEN")
}
}
}
publications {
create<MavenPublication>("mavenJava") {
from(components["java"])
groupId = project.group.toString()
artifactId = project.name
version = project.version.toString()
}
}
}

Publishing:

Terminal window
./gradlew publish # publishes to configured Maven repo

CI publishes only on tag; PR builds skip publish.

7.6 Auto-configuration manifest

SDKs that auto-configure Spring beans ship META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:

src/main/resources/META-INF/spring/
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports

Contents — one FQN per line:

com.example.platform.messaging.config.EventMessagingAutoConfiguration

7.7 SemVer policy

Version part Bump when
MAJOR Breaking API change (port shape, removed bean, behaviour shift) — ADR required
MINOR Backward-compatible feature (new bean, new optional config property)
PATCH Bug fix, dep bump, no API impact

Tag releases on the main branch (git tag v1.2.3). CI workflow publishes on tag push.


8. BOM Management

8.1 Import BOMs, don’t pin versions

Single-version-source for transitive deps. In subprojects {}:

dependencyManagement {
imports {
mavenBom("org.springframework.boot:spring-boot-dependencies:${property("spring.boot.version")}")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("spring.cloud.version")}")
}
}

Inside dependencies {}, omit versions for anything covered by a BOM:

// GOOD — BOM resolves
implementation("org.springframework.boot:spring-boot-starter-web")
// BAD — drift risk
implementation("org.springframework.boot:spring-boot-starter-web:4.0.2")

8.2 Common BOMs

BOM Group:artifact Pins
Spring Boot org.springframework.boot:spring-boot-dependencies Spring + Jackson + Hibernate + most testing
Spring Cloud org.springframework.cloud:spring-cloud-dependencies Cloud config, gateway, sleuth
AWS Spring Cloud io.awspring.cloud:spring-cloud-aws-dependencies AWS SDK alignment
JUnit org.junit:junit-bom Jupiter + platform
Testcontainers org.testcontainers:testcontainers-bom All TC modules
Jackson (non-Spring) tools.jackson:jackson-bom Jackson 3.x components

8.3 Multi-BOM coexistence

When two BOMs collide on the same artifact (e.g., Spring Boot vs Spring Cloud pin different spring-core), Spring Dependency Management resolves by import order — later imports win. Document with a comment in the build file.


9. Repository Strategy

9.1 Order

allprojects {
repositories {
mavenLocal() // dev only — see §9.3
mavenCentral() // canonical OSS
maven { // private registry
name = "GitHubPackages"
url = uri("${project.findProperty("github_packages_url")}/your-org")
credentials {
username = System.getenv("GH_PACKAGES_USERNAME")
password = System.getenv("GH_PACKAGES_TOKEN")
}
}
}
}

Order matters: first hit wins. Local before remote allows fast iteration when developing an SDK + consumer in parallel.

9.2 Credentials

  • NEVER commit credentials. Use env vars OR ~/.gradle/gradle.properties.
  • CI: secret store → env vars → Gradle.
  • Local dev: ~/.gradle/gradle.properties is git-ignored by ~/.
# ~/.gradle/gradle.properties (NOT committed)
GH_PACKAGES_USERNAME=yourgithubuser
GH_PACKAGES_TOKEN=ghp_xxx

Then in build:

credentials {
username = System.getenv("GH_PACKAGES_USERNAME") ?: providers.gradleProperty("GH_PACKAGES_USERNAME").orNull
password = System.getenv("GH_PACKAGES_TOKEN") ?: providers.gradleProperty("GH_PACKAGES_TOKEN").orNull
}

9.3 mavenLocal() warning

Convenient locally, dangerous in CI: builds may pick up stale jars from prior local runs. CI MUST set --no-build-cache or drop mavenLocal() entirely.

9.4 Verification (supply chain)

Enable Gradle dependency verification once per project:

Terminal window
./gradlew --write-verification-metadata pgp,sha256

Commit gradle/verification-metadata.xml. Future builds verify checksums + PGP signatures against the lockfile. See security.md §14.3.


10. Resolution Strategy

10.1 When you need it

Cross-cutting transitive conflicts that the BOM doesn’t resolve. Example: WireMock 3.6.0 (jetty12 module) pins Jetty ee10 to 12.0.14, but Spring Boot 4 brings jetty-core 12.1.5. Mixing breaks at runtime with NoSuchMethodError: Environment.ensure. Jetty is test-only, production uses Tomcat. Force test classpath to 12.0.x:

configurations.testRuntimeClasspath {
resolutionStrategy.eachDependency {
if (requested.group == "org.eclipse.jetty"
|| requested.group.startsWith("org.eclipse.jetty.")) {
useVersion("12.0.15")
because("Avoid jetty-core 12.1.x vs ee10 12.0.x NoSuchMethodError")
}
}
}

10.2 Rules

  • Always include a because(...) explaining the override. Future readers need it.
  • Scope to the narrowest configuration. testRuntimeClasspath is better than runtimeClasspath which is better than all.
  • Re-evaluate on every major dep bump — the workaround may no longer be needed.
  • Prefer fixing the root cause (upgrade the conflicting library) over a permanent pin.

10.3 Force module exclusion

When a transitive dep ships a problematic library:

implementation("com.github.javafaker:javafaker:1.0.2") {
exclude(group = "org.yaml") // pulls vulnerable SnakeYAML
}

11. Custom Tasks

11.1 createMigrationFile — Liquibase

In :jdbc-repository/build.gradle.kts:

import java.text.SimpleDateFormat
import java.util.Date
tasks.register("createMigrationFile") {
description = "Generates Liquibase migration file with timestamp"
doLast {
val timestamp = SimpleDateFormat("yyyyMMddHHmmss").format(Date())
val migrationFileName = "V${timestamp}__change_name_here.sql"
val migrationFilePath = "src/main/resources/db/changelog/changes/$migrationFileName"
file(migrationFilePath).createNewFile()
println("Created migration file: $migrationFileName")
}
}

Usage:

Terminal window
./gradlew :jdbc-repository:createMigrationFile

Then rename change_name_here and write the changeset. See liquibase.md.

11.2 jacocoLogCodeCoverage — coverage table

Root build.gradle.kts — prints per-module + total coverage to stdout after codeCoverageReport:

tasks.register("jacocoLogCodeCoverage") {
description = "Logs code coverage summary per subproject and total from JaCoCo XML reports."
group = JavaBasePlugin.VERIFICATION_GROUP
dependsOn("codeCoverageReport")
doLast {
val excludedPackages = listOf("config")
var totalInstr = 0L to 0L // (covered, missed)
var totalBranch = 0L to 0L
var totalLine = 0L to 0L
val separator = "-".repeat(90)
println("\n$separator")
println(" JaCoCo Code Coverage Summary")
println(separator)
println(String.format(" %-30s %12s %12s %12s", "Module", "Instruction%", "Branch%", "Line%"))
println(separator)
subprojects.sortedBy { it.name }.forEach { sub ->
val reportFile = file("${sub.layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml")
if (!reportFile.exists()) return@forEach
val doc = javax.xml.parsers.DocumentBuilderFactory.newInstance().apply {
setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
}.newDocumentBuilder().parse(reportFile)
var instr = 0L to 0L; var branch = 0L to 0L; var line = 0L to 0L
val packages = doc.getElementsByTagName("package")
for (i in 0 until packages.length) {
val pkg = packages.item(i) as org.w3c.dom.Element
val pkgName = pkg.getAttribute("name")
if (excludedPackages.any { pkgName.contains(it) }) continue
val counters = pkg.getElementsByTagName("counter")
for (j in 0 until counters.length) {
val counter = counters.item(j) as org.w3c.dom.Element
if (counter.parentNode != pkg) continue
val missed = counter.getAttribute("missed").toLong()
val covered = counter.getAttribute("covered").toLong()
when (counter.getAttribute("type")) {
"INSTRUCTION" -> instr = (instr.first + covered) to (instr.second + missed)
"BRANCH" -> branch = (branch.first + covered) to (branch.second + missed)
"LINE" -> line = (line.first + covered) to (line.second + missed)
}
}
}
totalInstr = (totalInstr.first + instr.first) to (totalInstr.second + instr.second)
totalBranch = (totalBranch.first + branch.first) to (totalBranch.second + branch.second)
totalLine = (totalLine.first + line.first) to (totalLine.second + line.second)
fun pct(c: Long, m: Long): String =
if ((c + m) == 0L) "N/A" else String.format("%.2f%%", c * 100.0 / (c + m))
println(String.format(" %-30s %12s %12s %12s",
sub.name,
pct(instr.first, instr.second),
pct(branch.first, branch.second),
pct(line.first, line.second)))
}
fun pct(c: Long, m: Long): String =
if ((c + m) == 0L) "N/A" else String.format("%.2f%%", c * 100.0 / (c + m))
println(separator)
println(String.format(" %-30s %12s %12s %12s",
"TOTAL",
pct(totalInstr.first, totalInstr.second),
pct(totalBranch.first, totalBranch.second),
pct(totalLine.first, totalLine.second)))
println("$separator\n")
}
}

Output:

------------------------------------------------------------------------------------------
JaCoCo Code Coverage Summary
------------------------------------------------------------------------------------------
Module Instruction% Branch% Line%
------------------------------------------------------------------------------------------
application 92.41% 85.71% 93.21%
domain 98.05% 91.30% 98.55%
jdbc-repository 87.62% 72.41% 89.10%
rest-controller 89.33% 78.92% 91.04%
------------------------------------------------------------------------------------------
TOTAL 91.85% 82.08% 93.22%
------------------------------------------------------------------------------------------

11.3 structurizrExport — see §4.6.

11.4 Task wiring

Hook Why
tasks.named("check") { dependsOn("detekt") } Detekt blocks CI
tasks.named("check") { dependsOn("jacocoTestCoverageVerification") } Coverage blocks CI
tasks.named("build") { dependsOn("spotlessCheck") } Formatting blocks CI
tasks.withType<Test> { finalizedBy("jacocoTestReport") } Coverage XML always produced

12. gradle.properties Convention

12.1 Naming

Two conventions exist in the Spring ecosystem:

Style Example When
dot.notation spring.boot.version, mapstruct.version Recommended — aligns with Spring property style
snake_case spring_boot_version, mapstruct_version Acceptable legacy; don’t mix in the same project

Pick one per project, document the choice in gradle.properties header.

12.2 Template

# Build engine
org.gradle.daemon=false # CI determinism; flip to `true` for local dev
org.gradle.parallel=true
org.gradle.caching=true
# Java toolchain
org.gradle.java.installations.auto-download=true
org.gradle.java.installations.auto-detect=true
# Framework BOMs
spring.boot.version=4.0.2
spring.cloud.version=2025.1.1
spring.dependency.management.version=1.1.7
# Plugins
sonarqube.plugin.version=7.1.0.6387
# Libraries
mapstruct.version=1.6.3
javafaker.version=1.0.2
slf4j.version=2.0.17
# Private registries (NEVER commit credentials)
github_packages_url=https://maven.pkg.github.com/your-org

12.3 org.gradle.daemon

  • true (default) — fast local iteration.
  • false — CI determinism; avoids stale state across builds.

Many teams keep it false in committed gradle.properties and let local devs override via ~/.gradle/gradle.properties. Document the choice.

12.4 Forbidden

  • Credentials (GH_PACKAGES_TOKEN=...).
  • Per-developer paths (local.dev.path=...).
  • Environment-specific values (active.profile=prod).

Use env vars or external secret store for those.


13. Pre-Merge Checklist

For PRs that touch any build.gradle.kts, settings.gradle.kts, or gradle.properties:

  • Plugin versions match §1.1; bumps justified in PR description.
  • No LATEST, +, or unpinned SNAPSHOT versions.
  • New module follows §3.3 dependency matrix (no cross-layer leaks).
  • New module declared in settings.gradle.kts with correct projectDir.
  • :bootstrap is the only module with bootJar.enabled = true.
  • ArchUnit test exists / passes for new module (see testing.md).
  • Common deps imported via BOM, no hardcoded versions where BOM resolves.
  • spotlessCheck passes (PostToolUse hook should have auto-fixed).
  • Detekt passes for any new Kotlin code.
  • JaCoCo coverage meets target (≥80% adapters, ≥90% domain/app/SDKs).
  • No credentials, secrets, or per-dev paths in gradle.properties.
  • Resolution-strategy overrides include because(...).
  • New <provider>-adapter module follows §6.3 pattern (one provider per module).
  • SDK changes: §7 checklist applied (compileOnly for Spring, Javadoc/sources jars, semver tag).
  • Repository order (§9.1) preserved if changed.
  • No mavenLocal() added to CI-only configurations.
  • CI build succeeds with ./gradlew clean check codeCoverageReport.
  • ADR drafted for: major version bump (Java/Gradle/Boot/Kotlin), new BOM, new plugin, new repository, new resolution-strategy override that persists.