Series overview
Part 2 of 1712% complete
2026-08-16•6 min read

Scaffold the multi-module build: Gradle 9, convention plugins, and architecture tests

Checkpoint tag: chapter-01-build-foundation — ./gradlew clean check passes on an empty application skeleton, and the first architecture rules are enforced.

What will be built

The complete repository skeleton: nine Gradle modules, an included build-logic build that supplies four convention plugins, a version catalog, a Java 25 toolchain, JUnit 5 test wiring, reproducible archives, two standing ArchUnit rules, and a GitHub Actions workflow. At the end of this chapter every application module compiles and boots nothing — deliberately. The point is that every later chapter only edits the module it owns.

Why it matters

In a single-module project, “the model layer must not depend on Spring AI” is a sentence in a README that rots within weeks. In a multi-module build it is a compile-time fact: domain-contracts literally does not have Spring AI on its classpath. Convention plugins matter for the same reason — without them, every module’s build file drifts into its own dialect of toolchain, test, and quality configuration, and the drift always shows up at the worst moment (CI green locally, red on main). ArchUnit exists for the boundaries Gradle cannot express — “web adapters must not touch repositories” is a package rule, not a dependency rule.

Prerequisites and starting point

Starting tag: none (empty repository). You need Docker not at all today; you need a JDK only if you disable Gradle’s toolchain auto-provisioning. Install Gradle once to generate the wrapper, or use the gradle wrapper bootstrap described below — after that, ./gradlew is the only entry point.

Architecture before and after

Before: an empty directory. After:

convention plugins

Modules

domain-contracts

agent-api

knowledge-ingestion

mcp-operations-server

operations-simulator

architecture-tests

test-support

evaluation-suite

build-logic (included build)

convention plugins

Modules

domain-contracts

agent-api

knowledge-ingestion

mcp-operations-server

operations-simulator

architecture-tests

test-support

evaluation-suite

build-logic (included build)

Concepts explained

Convention plugins (build-logic + includeBuild) are precompiled Kotlin DSL scripts that bundle your own defaults. Each module then says id("ops.spring-application") instead of repeating toolchain, compiler, and test config. Gradle resolves ops.* plugin IDs to the scripts in build-logic/src/main/kotlin.

BOMs vs. starters. A BOM (spring-boot-dependencies, spring-ai-bom) manages versions via platform(...); a starter pulls in functionality. Never pin a version that a BOM already manages — that is how half-upgraded classpaths happen.

Toolchains decouple “the JDK running Gradle” from “the JDK compiling and testing your code.” languageVersion = 25 on every module means a JDK 21 host still produces Java 25 bytecode via auto-provisioned toolchains.

Boot 4 starter names. Spring Boot 4 renamed its starters: spring-boot-starter-web is deprecated for spring-boot-starter-webmvc, OAuth starters moved under spring-boot-starter-security-*, and spring-boot-starter-test is slimmer (spring-boot-starter-test-classic restores the old bundle). This series uses the new names throughout.

Files added or changed

gradle.properties
settings.gradle.kts
build.gradle.kts
gradle/libs.versions.toml
build-logic/settings.gradle.kts
build-logic/build.gradle.kts
build-logic/src/main/kotlin/ops.java-library.gradle.kts
build-logic/src/main/kotlin/ops.kotlin-library.gradle.kts
build-logic/src/main/kotlin/ops.spring-application.gradle.kts
build-logic/src/main/kotlin/ops.quality.gradle.kts
domain-contracts/build.gradle.kts
operations-simulator/{build.gradle.kts, src/main/java/.../SimulatorApplication.java}
agent-api/{build.gradle.kts, src/main/java/.../AgentApiApplication.java}
knowledge-ingestion/{build.gradle.kts, src/main/java/.../IngestionApplication.java}
mcp-operations-server/{build.gradle.kts, src/main/kotlin/.../McpServerApplication.kt}
test-support/build.gradle.kts
evaluation-suite/build.gradle.kts
architecture-tests/{build.gradle.kts, src/test/java/.../BoundaryRulesTest.java}
.github/workflows/ci.yml

Implementation steps

  1. gradle wrapper --gradle-version 9.7.1 in the repo root (or hand-create the wrapper files once), then never touch a global Gradle again.
  2. Write settings.gradle.kts declaring includeBuild("build-logic") and the eight modules.
  3. Write gradle/libs.versions.toml with every non-BOM-managed version.
  4. Write the four convention plugins.
  5. Write each module’s build.gradle.kts plus a minimal application entry point.
  6. Write the first ArchUnit rules.
  7. ./gradlew clean check until green; commit; add CI.

Complete code

gradle.properties

gradle.properties
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.warning.mode=all

settings.gradle.kts — the Foojay plugin is applied at top level so the Java 25 toolchain auto-provisions on machines that only have an older JDK:

settings.gradle.kts
plugins {
// Auto-provision toolchains (e.g. JDK 25) when not installed locally
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "ops-agent-platform"
includeBuild("build-logic")
include(
"domain-contracts",
"agent-api",
"knowledge-ingestion",
"mcp-operations-server",
"operations-simulator",
"test-support",
"evaluation-suite",
"architecture-tests",
)

gradle/libs.versions.toml

gradle/libs.versions.toml
[versions]
springBoot = "4.1.1"
springAi = "2.0.1"
kotlin = "2.3.21"
testcontainers = "2.0.5"
archunit = "1.4.1"
awaitility = "4.3.0"
wiremock = "3.13.1"
[libraries]
spring-ai-bom = { module = "org.springframework.ai:spring-ai-bom", version.ref = "springAi" }
testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" }
assertj = { module = "org.assertj:assertj-core" }
awaitility = { module = "org.awaitility:awaitility", version.ref = "awaitility" }
archunit-junit5 = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" }
wiremock = { module = "org.wiremock:wiremock-standalone", version.ref = "wiremock" }
# Testcontainers 2.x renamed its artifacts — the bare `postgresql`/`junit-jupiter`
# names do not exist in the 2.0.5 BOM.
testcontainers-postgresql = { module = "org.testcontainers:testcontainers-postgresql" }
testcontainers-junit = { module = "org.testcontainers:testcontainers-junit-jupiter" }
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }

Versions the Spring Boot BOM already manages (JUnit, AssertJ, Mockito, Jackson, Flyway, Testcontainers) intentionally have no [versions] entry — pinning them anyway is the classic source of a half-managed classpath.

build-logic/settings.gradle.kts

build-logic/settings.gradle.kts
rootProject.name = "build-logic"
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}

build-logic/build.gradle.kts — the plugin jars live on this classpath so id("org.springframework.boot") inside a convention script resolves without a plugin-portal lookup:

build-logic/build.gradle.kts
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
gradlePluginPortal()
}
dependencies {
implementation("org.springframework.boot:spring-boot-gradle-plugin:4.1.1")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21")
// kotlin-allopen carries the org.jetbrains.kotlin.plugin.spring plugin —
// kotlin-gradle-plugin alone does not provide it.
implementation("org.jetbrains.kotlin:kotlin-allopen:2.3.21")
}

build-logic/src/main/kotlin/ops.java-library.gradle.kts

build-logic/src/main/kotlin/ops.java-library.gradle.kts
plugins {
`java-library` // java-library, not java — api/implementation separation matters here
jacoco
id("ops.quality")
}
group = "in.o612.eng.opsagent"
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.compilerArgs.addAll(listOf("-parameters"))
options.release.set(25)
}
dependencies {
// Test classpath versions come from the Boot BOM even on plain libraries:
// one managed version universe for the whole build.
testImplementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.1"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.assertj:assertj-core")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events("passed", "failed", "skipped")
showStandardStreams = false
}
finalizedBy(tasks.jacocoTestReport)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required.set(true)
html.required.set(true)
}
}

build-logic/src/main/kotlin/ops.kotlin-library.gradle.kts

build-logic/src/main/kotlin/ops.kotlin-library.gradle.kts
plugins {
id("ops.java-library")
id("org.jetbrains.kotlin.jvm")
id("org.jetbrains.kotlin.plugin.spring")
}
kotlin {
jvmToolchain(25)
}

build-logic/src/main/kotlin/ops.spring-application.gradle.kts

build-logic/src/main/kotlin/ops.spring-application.gradle.kts
plugins {
id("ops.java-library")
id("org.springframework.boot")
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.1"))
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
}
// No mainClass wiring needed: Boot's resolveMainClassName task auto-detects
// the single @SpringBootApplication class in each module.

build-logic/src/main/kotlin/ops.quality.gradle.kts

build-logic/src/main/kotlin/ops.quality.gradle.kts
// Reproducible archives. Deliberately NO failOnVersionConflict(): once
// spring-ai-bom sits next to spring-boot-dependencies, the two platforms
// legitimately disagree on transitive versions and Gradle's default
// resolution handles it. Drift is guarded by the version catalog +
// `./gradlew dependencyInsight` on review, not by a hard fail here.
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}

domain-contracts/build.gradle.kts — framework-light on purpose: Jackson annotations for the JSON boundary types, nothing else.

domain-contracts/build.gradle.kts
plugins {
id("ops.java-library")
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.1"))
api("com.fasterxml.jackson.core:jackson-annotations")
}

operations-simulator/build.gradle.kts

operations-simulator/build.gradle.kts
plugins {
id("ops.spring-application")
}
dependencies {
implementation(project(":domain-contracts"))
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-flyway")
runtimeOnly("org.postgresql:postgresql")
}

operations-simulator/src/main/java/in/o612/eng/opsagent/simulator/SimulatorApplication.java

operations-simulator/src/main/java/in/o612/eng/opsagent/simulator/SimulatorApplication.java
package in.o612.eng.opsagent.simulator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SimulatorApplication {
public static void main(String[] args) {
SpringApplication.run(SimulatorApplication.class, args);
}
}

agent-api/build.gradle.kts

agent-api/build.gradle.kts
plugins {
id("ops.spring-application")
}
dependencies {
implementation(project(":domain-contracts"))
implementation(platform(libs.spring.ai.bom))
implementation("org.springframework.boot:spring-boot-starter-webmvc")
}

agent-api/src/main/java/in/o612/eng/opsagent/agent/AgentApiApplication.java

agent-api/src/main/java/in/o612/eng/opsagent/agent/AgentApiApplication.java
package in.o612.eng.opsagent.agent;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AgentApiApplication {
public static void main(String[] args) {
SpringApplication.run(AgentApiApplication.class, args);
}
}

knowledge-ingestion mirrors operations-simulator minus the web starter (it is a command-line job): implementation("org.springframework.boot:spring-boot-jdbc") plus spring-boot-starter-flyway and the Postgres driver, with an IngestionApplication class identical in shape.

mcp-operations-server/build.gradle.kts — the Kotlin module. Note the plugins block: id("org.springframework.boot") cannot be applied directly in a project build file (the plugin marker isn’t on the project’s plugin classpath), so the Boot plugin arrives via ops.spring-application:

mcp-operations-server/build.gradle.kts
plugins {
id("ops.kotlin-library")
id("ops.spring-application") // brings org.springframework.boot + BOM + test deps
}
dependencies {
implementation(platform(libs.spring.ai.bom))
implementation(project(":domain-contracts"))
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc")
implementation("org.jetbrains.kotlin:kotlin-stdlib")
}

mcp-operations-server/src/main/kotlin/in/o612/eng/opsagent/mcp/McpServerApplication.kt — note the backticks: in is a Kotlin keyword, so every Kotlin package declaration in this project escapes it.

mcp-operations-server/src/main/kotlin/in/o612/eng/opsagent/mcp/McpServerApplication.kt
package `in`.o612.eng.opsagent.mcp
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class McpServerApplication
fun main(args: Array<String>) {
runApplication<McpServerApplication>(*args)
}

test-support/build.gradle.kts — fixtures only; nothing here may reach a production classpath.

test-support/build.gradle.kts
plugins {
id("ops.java-library")
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.1"))
// api(platform) carries the BOM to consumers' classpaths; the
// testImplementation platform is needed separately — an api-scoped
// platform does not constrain our own test classpath.
api(platform(libs.testcontainers.bom))
testImplementation(platform(libs.testcontainers.bom))
api(libs.testcontainers.postgresql)
api(libs.testcontainers.junit)
api(libs.assertj)
api(libs.awaitility)
implementation("org.springframework.boot:spring-boot-starter-test")
}

evaluation-suite and architecture-tests start as ops.java-library shells; architecture-tests additionally depends on the modules it inspects:

architecture-tests/build.gradle.kts
plugins {
id("ops.java-library")
}
dependencies {
testImplementation(project(":domain-contracts"))
testImplementation(project(":operations-simulator"))
testImplementation(project(":agent-api"))
testImplementation(project(":mcp-operations-server"))
testImplementation(libs.archunit.junit5)
}

architecture-tests/src/test/java/in/o612/eng/opsagent/arch/BoundaryRulesTest.java — the two rules that must hold for the entire series:

architecture-tests/src/test/java/in/o612/eng/opsagent/arch/BoundaryRulesTest.java
package in.o612.eng.opsagent.arch;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "in.o612.eng.opsagent")
class BoundaryRulesTest {
@ArchTest
static final ArchRule contracts_must_not_depend_on_framework_or_ai =
noClasses()
.that().resideInAPackage("in.o612.eng.opsagent.contracts..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework.ai..",
"jakarta.persistence..",
"org.springframework.web..",
"io.modelcontextprotocol..")
.allowEmptyShould(true);
@ArchTest
static final ArchRule web_adapters_must_not_touch_repositories =
noClasses()
.that().resideInAPackage("..web..")
.should().dependOnClassesThat().resideInAPackage("..persistence..")
.allowEmptyShould(true);
}

.github/workflows/ci.yml

.github/workflows/ci.yml
name: ci
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew clean check
env:
CI: true

Commands to build and run

terminal
gradle wrapper --gradle-version 9.7.1 # once, to bootstrap
./gradlew clean check # compile, test, arch rules, coverage
./gradlew :operations-simulator:bootJar # produces an empty-but-real boot jar

Expected: BUILD SUCCESSFUL with a handful of no tests found-adjacent output for modules that only contain a main class. Nothing contacts an external service.

Verification note: this scaffold was built and checked against the pinned versions — ./gradlew clean check green on Gradle 9.7.1 with a provisioned JDK 25 toolchain, and both Java and Kotlin bootJars resolve their main classes automatically.

Failure-injection lab

The architecture tests should fail when the boundary is violated — prove it once now so you trust them later:

  1. Add import jakarta.persistence.Entity; to any class under in.o612.eng.opsagent.contracts (you will need a temporary implementation dep, which is itself instructive — the dependency has to exist on the classpath before the rule can even see it).
  2. ./gradlew :architecture-tests:test → contracts_must_not_depend_on_framework_or_ai fails with the violating class listed.
  3. Remove it. Green again.

The more interesting lesson: a rule can only guard what is on the classpath. Reviewers should still look at new dependencies {} blocks; ArchUnit is the second line, not the first.

Security considerations

  • A single version catalog is the dependency chokepoint — no module may pin its own version of a BOM-managed artifact. (We tried failOnVersionConflict() in ops.quality during verification; it fails multi-BOM builds, so the guard is the catalog plus review, not a resolution flag.)
  • Reproducible archives make sha256sum comparisons between CI runs meaningful — a prerequisite for any dependency-verification story later.
  • No secrets, no credentials, no network calls in the default build; the wrapper checksum is verified by gradle/actions/setup-gradle in CI.

Troubleshooting

  • KotlinJvmTarget mismatch: both the Java toolchain (25) and jvmToolchain(25) must agree; if you change one, change both.
  • ops.spring-application not found: includeBuild("build-logic") must precede include(...) resolution; check settings.gradle.kts.
  • Toolchain cannot resolve Java 25: set org.gradle.java.installations.paths or enable auto-download via the toolchain resolver plugin.

Checkpoint verification checklist

  • ./gradlew clean check is green from a clean checkout.
  • ./gradlew projects lists all eight modules plus build-logic.
  • Both ArchUnit rules pass and demonstrably fail when violated.
  • domain-contracts has no Spring AI, JPA, or web dependency.
  • CI workflow runs the same single command you ran locally.

Commit message and Git tag

chore: scaffold ops-agent-platform multi-module build
Gradle 9.7.1 wrapper, version catalog, four convention plugins,
Java 25 toolchains, JUnit 5 + JaCoCo, first ArchUnit boundary
rules, and CI.

git tag chapter-01-build-foundation

What comes next

Chapter 2 puts the first real behavior behind operations-simulator: the service catalog, incidents, idempotent creation, and the fault modes every later resilience test leans on.

Project State Ledger — chapter-01-build-foundation

  • Build: Gradle 9.7.1, Java 25 toolchain, in.o612.eng.opsagent group, convention plugins ops.java-library / ops.kotlin-library / ops.spring-application / ops.quality
  • Modules: all eight exist; four applications have main classes only
  • Enforced rules: contracts isolation; web↛persistence
  • Known limitation: Kotlin module compiles but has no MCP tools yet; Boot 4 starter names (spring-boot-starter-webmvc etc.) used throughout
  • Next: chapter-02-operations-simulator
JavaSpring BootKotlinTesting

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind