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/ serviceplatform-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:
- hexagonal-architecture.md — layer rules enforced by module dependencies.
- java-code-style.md / kotlin-code-style.md — language conventions.
- testing.md — JaCoCo coverage targets, ArchUnit, Karate.
- liquibase.md — migration changeset conventions.
- messaging.md — broker dependency stacks.
- observability.md — Micrometer + OTEL deps.
Table of Contents
- Versions & Policy
- Compiler Configuration
- Multi-Module Structure
- Plugins
- Common Dependencies
- Module Conventions & Naming
- SDK / Library Publishing
- BOM Management
- Repository Strategy
- Resolution Strategy
- Custom Tasks
- gradle.properties Convention
- 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,+,SNAPSHOTin production builds. CI rejects onmain.- 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/@Builderon 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 insrc/main/kotlin/. - Apply both
id("java")andkotlin("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 # versions3.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 pointsinclude(":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 adaptersinclude(":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):
:domainMUST have zero module deps.:applicationMUST depend only on:domain.- Infrastructure modules depend only on
:application+:domain. - Only
:bootstrapappliesorg.springframework.bootplugin 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:
./gradlew spotlessCheck # CI gate./gradlew spotlessApply # auto-fixA PostToolUse hook (
~/.claude/hooks/claude-spotless-apply.sh) runsspotlessApplyafter 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: falseUsage:
./gradlew detekt./gradlew :module:detekt./gradlew detektBaseline # legacy code intake./gradlew detektGenerateConfig # full default config to customize4.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
jacocoTestCoverageVerificationgatingcheck.
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:
./gradlew sonar -Dsonar.token=$SONAR_TOKENCI: 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
:bootstrapvia@Profileor@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:
./gradlew publish # publishes to configured Maven repoCI 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.importsContents — one FQN per line:
com.example.platform.messaging.config.EventMessagingAutoConfiguration7.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 resolvesimplementation("org.springframework.boot:spring-boot-starter-web")
// BAD — drift riskimplementation("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.propertiesis git-ignored by~/.
# ~/.gradle/gradle.properties (NOT committed)GH_PACKAGES_USERNAME=yourgithubuserGH_PACKAGES_TOKEN=ghp_xxxThen 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:
./gradlew --write-verification-metadata pgp,sha256Commit 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.
testRuntimeClasspathis better thanruntimeClasspathwhich is better thanall. - 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.SimpleDateFormatimport 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:
./gradlew :jdbc-repository:createMigrationFileThen 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 engineorg.gradle.daemon=false # CI determinism; flip to `true` for local devorg.gradle.parallel=trueorg.gradle.caching=true
# Java toolchainorg.gradle.java.installations.auto-download=trueorg.gradle.java.installations.auto-detect=true
# Framework BOMsspring.boot.version=4.0.2spring.cloud.version=2025.1.1spring.dependency.management.version=1.1.7
# Pluginssonarqube.plugin.version=7.1.0.6387
# Librariesmapstruct.version=1.6.3javafaker.version=1.0.2slf4j.version=2.0.17
# Private registries (NEVER commit credentials)github_packages_url=https://maven.pkg.github.com/your-org12.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.ktswith correctprojectDir. -
:bootstrapis the only module withbootJar.enabled = true. - ArchUnit test exists / passes for new module (see testing.md).
- Common deps imported via BOM, no hardcoded versions where BOM resolves.
-
spotlessCheckpasses (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>-adaptermodule follows §6.3 pattern (one provider per module). - SDK changes: §7 checklist applied (
compileOnlyfor 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.