Saltar al contenido
Mis Guidelines
Guías
En esta guía (64 elementos)

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]

ToolVersionNotes
Java (toolchain)21 LTSUse Gradle toolchain — never rely on JAVA_HOME. Project will auto-download.
Gradle wrapper9.0.x8.13 acceptable transitionally; bump on next milestone.
Kotlin2.2.xOptional. Only for Kotlin-source modules.
Spring Boot4.0.xBOM-managed.
Spring Cloud2025.xAligns with Boot 4.0.
Spring Dependency Management1.1.7BOM importer plugin.

1.2 Version-bump policy

ChangeApprovalTrigger
Patch bump (x.y.Z)Self-merge if greenSecurity patches, bug fixes
Minor bump (x.Y.z)PR reviewFeature uptake
Major bump (X.y.z) of: Java toolchain, Gradle wrapper, Spring Boot, KotlinADR requiredBreaking changes likely
Plugin major bumpPR review + smoke test on all modules
Adding new dep with transitive CVE riskPR + 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

ModuleDepends onPurpose
:domain(none)Aggregates, value objects, domain events, enums
:application:domainUse cases, inbound + outbound ports, commands
:rest-controller:application, :domainHTTP entry point
:<topic>-consumer:application, :domainBroker entry point (Kafka/RabbitMQ/SQS)
:scheduler-task:application, :domainScheduled jobs
:jdbc-repository:application, :domainDB persistence (JDBC + Liquibase)
:redis-cache:application, :domainCache adapter
:metrics-publisher:application, :domainObservability decorator
:<provider>-adapter:application, :domainThird-party integration (per provider)
:bootstrapALL modulesSpring 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.8". Apply ONLY to Kotlin subprojects. Detekt 2.x (dev.detekt, Kotlin 2.2+) is still alpha — stay on 1.23.x until it goes GA.

plugins {
id("io.gitlab.arturbosch.detekt") version "1.23.8" 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.8"
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.8")
}
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.5.1")
}

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")
// Testcontainers 2.x — version managed by the Spring Boot 4 BOM; 2.x artifacts carry the `testcontainers-` prefix
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
testImplementation("org.testcontainers:testcontainers-postgresql") // OR testcontainers-mssqlserver
testImplementation("org.testcontainers:testcontainers-rabbitmq") // OR testcontainers-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

ModuleConvention
:domainSingular. Pure logic. No @Spring* annotations.
:applicationUse cases + ports. No framework imports.
:bootstrapOne per service. Contains MainApplication, config/, application.yml.

6.2 Entry points (inbound adapters)

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

PatternExampleWhen
rest-controllerrest-controllerHTTP API
graphql-controllergraphql-controllerGraphQL API
<topic>-consumerorder-consumer, payment-consumerOne per major topic family — keeps consumer groups isolated
scheduler-taskscheduler-taskCron/scheduled work
webhook-receiverstripe-webhook-receiverExternal callbacks

6.3 Driven adapters (outbound)

PatternExampleWhen
jdbc-repositoryjdbc-repositoryDB persistence (one module total)
redis-cacheredis-cacheCache layer
metrics-publishermetrics-publisherObservability decorator
<topic>-producerevent-producerOutbound broker
<provider>-adapterstripe-adapter, paypal-adapter, twilio-adapterThird-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:testcontainers-postgresql") // version from Spring Boot BOM
}
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 partBump when
MAJORBreaking API change (port shape, removed bean, behaviour shift) — ADR required
MINORBackward-compatible feature (new bean, new optional config property)
PATCHBug 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

BOMGroup:artifactPins
Spring Bootorg.springframework.boot:spring-boot-dependenciesSpring + Jackson + Hibernate + most testing
Spring Cloudorg.springframework.cloud:spring-cloud-dependenciesCloud config, gateway, sleuth
AWS Spring Cloudio.awspring.cloud:spring-cloud-aws-dependenciesAWS SDK alignment
JUnitorg.junit:junit-bomJupiter + platform
Testcontainersorg.testcontainers:testcontainers-bomAll TC modules (already managed by Spring Boot 4; import only outside Boot)
Jackson (non-Spring)tools.jackson:jackson-bomJackson 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

HookWhy
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:

StyleExampleWhen
dot.notationspring.boot.version, mapstruct.versionRecommended — aligns with Spring property style
snake_casespring_boot_version, mapstruct_versionAcceptable 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.