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:
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.propertiessettings.gradle.ktsbuild.gradle.ktsgradle/libs.versions.tomlbuild-logic/settings.gradle.ktsbuild-logic/build.gradle.ktsbuild-logic/src/main/kotlin/ops.java-library.gradle.ktsbuild-logic/src/main/kotlin/ops.kotlin-library.gradle.ktsbuild-logic/src/main/kotlin/ops.spring-application.gradle.ktsbuild-logic/src/main/kotlin/ops.quality.gradle.ktsdomain-contracts/build.gradle.ktsoperations-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.ktsevaluation-suite/build.gradle.ktsarchitecture-tests/{build.gradle.kts, src/test/java/.../BoundaryRulesTest.java}.github/workflows/ci.ymlImplementation steps
gradle wrapper --gradle-version 9.7.1in the repo root (or hand-create the wrapper files once), then never touch a global Gradle again.- Write
settings.gradle.ktsdeclaringincludeBuild("build-logic")and the eight modules. - Write
gradle/libs.versions.tomlwith every non-BOM-managed version. - Write the four convention plugins.
- Write each module’s
build.gradle.ktsplus a minimal application entry point. - Write the first ArchUnit rules.
./gradlew clean checkuntil green; commit; add CI.
Complete code
gradle.properties
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8org.gradle.parallel=trueorg.gradle.caching=trueorg.gradle.configuration-cache=trueorg.gradle.warning.mode=allsettings.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:
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
[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
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:
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
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
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
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
// 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.
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
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
package in.o612.eng.opsagent.simulator;
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic class SimulatorApplication {
public static void main(String[] args) { SpringApplication.run(SimulatorApplication.class, args); }}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
package in.o612.eng.opsagent.agent;
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic 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:
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.
package `in`.o612.eng.opsagent.mcp
import org.springframework.boot.autoconfigure.SpringBootApplicationimport org.springframework.boot.runApplication
@SpringBootApplicationclass McpServerApplication
fun main(args: Array<String>) { runApplication<McpServerApplication>(*args)}test-support/build.gradle.kts — fixtures only; nothing here may reach a production classpath.
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:
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:
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
name: cion: 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: trueCommands to build and run
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 jarExpected: 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:
- Add
import jakarta.persistence.Entity;to any class underin.o612.eng.opsagent.contracts(you will need a temporaryimplementationdep, which is itself instructive — the dependency has to exist on the classpath before the rule can even see it). ./gradlew :architecture-tests:test→contracts_must_not_depend_on_framework_or_aifails with the violating class listed.- 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()inops.qualityduring verification; it fails multi-BOM builds, so the guard is the catalog plus review, not a resolution flag.) - Reproducible archives make
sha256sumcomparisons 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-gradlein CI.
Troubleshooting
KotlinJvmTargetmismatch: both the Java toolchain (25) andjvmToolchain(25)must agree; if you change one, change both.ops.spring-applicationnot found:includeBuild("build-logic")must precedeinclude(...)resolution; checksettings.gradle.kts.- Toolchain cannot resolve Java 25: set
org.gradle.java.installations.pathsor enable auto-download via the toolchain resolver plugin.
Checkpoint verification checklist
-
./gradlew clean checkis green from a clean checkout. -
./gradlew projectslists all eight modules plusbuild-logic. - Both ArchUnit rules pass and demonstrably fail when violated.
-
domain-contractshas 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 boundaryrules, 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.opsagentgroup, convention pluginsops.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-webmvcetc.) used throughout - Next:
chapter-02-operations-simulator