Home / Publications / 2026

// Best Practice Guide

AI-Assisted Delivery: Java & Spring Boot Edition

Hasan Ozgan

A concrete realization of the general guide for Java 21 + Spring Boot 3.x + Gradle (Maven equivalents apply). The objective: encircle agent velocity with the compiler, ArchUnit, PIT mutation testing, and automated CI.

1. Recommended Stack

DomainTooling
Build SystemGradle (Kotlin DSL) + Version Catalog, Dependency Locking (--write-locks)
Code FormattingSpotless (google-java-format or Palantir Java Format)
Static AnalysisError Prone + NullAway, SpotBugs, Checkstyle (lightweight rules), SonarQube
Testing FrameworksJUnit 5, AssertJ, Mockito (minimal), Testcontainers, @DataJpaTest, @WebMvcTest
Architecture EnforcementArchUnit, Spring Modulith (ApplicationModules.verify())
Mutation TestingPIT (Pitest) + pitest-junit5-plugin
Property-Based Testingjqwik
Contract TestingSpring Cloud Contract or Pact
Code CoverageJaCoCo (diff coverage enforced via diff-cover or Sonar Quality Gate)
Security AuditingCodeQL / Semgrep, OWASP Dependency-Check or OSV-Scanner, gitleaks
Database MigrationsFlyway (agents must never modify historical migration files)
Automated RefactoringOpenRewrite (relegate deterministic transformations to recipes, not LLMs)

2. Repository Structure (Modular Monolith)

src/main/java/com/acme/orders/
  OrdersModule/       <- package-private module entry point & API boundaries
  domain/             <- pure Java business logic, ZERO Spring/Jakarta/ORM imports
  application/        <- use cases, port interfaces, orchestrators
  adapter/
    in/web/           <- @RestController, request/response DTO records
    out/persistence/  <- JPA entities, Spring Data repository implementations
  config/             <- framework configuration beans
docs/adr, docs/specs
.claude/skills, .claude/settings.json
AGENTS.md  build.gradle.kts

A hexagonal (ports and adapters) layout provides an ideal boundary for AI systems: component responsibilities are unambiguous, and boundary violations can be enforced directly by automated ArchUnit assertions.

3. AGENTS.md (Java & Spring Boot Template)

# Orders Service
Java 21, Spring Boot 3.3, Gradle, PostgreSQL, Flyway.

## Core Commands
- Fast check: `./gradlew spotlessApply check -x pitest`
- Full quality gate: `./gradlew check pitest`
- Single test target: `./gradlew test --tests "*OrderServiceTest"`
- Integration suite: `./gradlew integrationTest` (requires Docker daemon)

## Architectural Invariants (Enforced via ArchUnit)
- The `domain` package must never import Spring, JPA/Hibernate, or Jackson packages
- REST controllers must never depend directly on persistence repositories; invoke use cases
- Rely exclusively on constructor injection; field injection via `@Autowired` is prohibited
- Entities must never be returned across API boundaries; map explicitly to immutable record DTOs

## Invariants & Prohibitions
- Never modify past Flyway migration files; always append a new `V__` script
- Never introduce `@Disabled`, `@Ignore`, or delete tests to green a broken build
- Never weaken assertion checks to make tests pass
- Ask before adding third-party dependencies in `build.gradle.kts`
- Prohibit `System.out.println`, `e.printStackTrace()`, or empty catch blocks

## Coding Style & Conventions
- Prefer `record`, sealed interfaces, and `Optional` return types; never return `null`
- Test naming convention: `should_<expectedResult>_when_<condition>`; use AssertJ assertions
- Prefer Testcontainers or in-memory test doubles over heavy Mockito mocking

## Definition of Done
`check` and `pitest` tasks execute cleanly; all spec acceptance criteria are validated by automated tests; ADR authored if architectural decisions changed.

4. Architectural Rules (ArchUnit)

Add the ArchUnit JUnit 5 dependency to build.gradle.kts:

testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")

Create an ArchitectureTest.java suite to enforce layer boundaries:

@AnalyzeClasses(packages = "com.acme", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {

  @ArchTest
  static final ArchRule layersAreRespected = layeredArchitecture()
      .consideringOnlyDependenciesInLayers()
      .layer("Web").definedBy("..adapter.in.web..")
      .layer("Application").definedBy("..application..")
      .layer("Domain").definedBy("..domain..")
      .layer("Persistence").definedBy("..adapter.out.persistence..")
      .whereLayer("Web").mayNotBeAccessedByAnyLayer()
      .whereLayer("Persistence").mayNotBeAccessedByAnyLayer()
      .whereLayer("Application").mayOnlyBeAccessedByLayers("Web", "Persistence")
      .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Web", "Persistence");

  @ArchTest
  static final ArchRule domainIsPure = noClasses().that().resideInAPackage("..domain..")
      .should().dependOnClassesThat().resideInAnyPackage(
          "org.springframework..", "jakarta.persistence..", "com.fasterxml.jackson..")
      .because("The domain layer must remain agnostic of frameworks and persistence technologies");

  @ArchTest
  static final ArchRule controllersNeverAccessRepositories = noClasses()
      .that().areAnnotatedWith(RestController.class)
      .should().dependOnClassesThat().areAssignableTo(Repository.class)
      .because("Controllers must interact with domain use cases, not persistence layers");

  @ArchTest
  static final ArchRule noFieldInjection = noFields()
      .should().beAnnotatedWith(Autowired.class)
      .because("Constructor injection ensures immutability and testability");

  @ArchTest
  static final ArchRule noPackageCycles = slices().matching("com.acme.(*)..")
      .should().beFreeOfCycles();

  @ArchTest
  static final ArchRule noStandardStreams = NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;

  @ArchTest
  static final ArchRule noGenericExceptions = NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS;

  @ArchTest
  static final ArchRule transactionalBelongsToApplication = methods()
      .that().areAnnotatedWith(Transactional.class)
      .should().beDeclaredInClassesThat().resideInAPackage("..application..")
      .because("Transaction boundaries must be orchestrated within the application layer");
}

Implementation Tips:

  • For brownfield codebases with existing violations, use FreezingArchRule.freeze(rule) to establish a baseline; new violations will fail the build immediately.
  • The .because(...) string gives autonomous agents clear actionable guidance on how to fix violations.
  • For modular monoliths, verify module boundaries using Spring Modulith: ApplicationModules.of(App.class).verify();.

5. Mutation Testing (PIT / Pitest)

Configure PIT in build.gradle.kts:

plugins {
  id("info.solidsoft.pitest") version "1.15.0"
}

pitest {
  junit5PluginVersion.set("1.2.1")
  pitestVersion.set("1.17.0")
  targetClasses.set(setOf("com.acme.*.domain.*", "com.acme.*.application.*"))
  excludedClasses.set(setOf("*Config", "*Application", "*Dto", "*Entity"))
  threads.set(Runtime.getRuntime().availableProcessors())
  mutators.set(setOf("STRONGER"))
  mutationThreshold.set(75)   // Target 80+ for core business modules
  coverageThreshold.set(80)
  outputFormats.set(setOf("HTML", "XML"))
  timestampedReports.set(false)
  withHistory.set(true)       // Incremental: unmutated code is skipped
  avoidCallsTo.set(setOf("org.slf4j", "java.util.logging"))
}

Execute on changed code during PRs using pitest-git-plugin:

./gradlew pitest -Pfeatures="+GIT(from[origin/main])"

Full mutation suites run during nightly CI. Focus mutation testing on domain and application layers; mutators applied to web controllers, boilerplate DTOs, or configuration classes produce unhelpful noise.

Survivor Triage Loop: Pass surviving mutants from build/reports/pitest/mutations.xml directly to an AI agent: "Author behavioral tests that kill these surviving mutants; do not modify production code."

6. Testing Practices

  • Testcontainers for PostgreSQL: Eliminate the risk of "green in H2, broken in production PostgreSQL" by testing against real database engines.
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
class OrderRepositoryIT {
  @Container
  @ServiceConnection
  static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
}
  • Targeted Test Slices: Leverage Spring's test slice annotations (@WebMvcTest for controllers, @DataJpaTest for data layers). Avoid heavy @SpringBootTest sweeps across every test file to keep verification fast.
  • Property-Based Testing with jqwik: Validate invariants with generated inputs:
@Property
void discountedPriceMustNeverBeNegative(
    @ForAll @Positive BigDecimal price,
    @ForAll @IntRange(min = 0, max = 100) int discountRate) {
  assertThat(PriceCalculator.applyDiscount(price, discountRate))
      .isGreaterThanOrEqualTo(BigDecimal.ZERO);
}
  • Deterministic Time: Always inject a Clock bean into domain services; ban LocalDateTime.now() invocations in application logic to avoid flaky assertions.
  • Mock at External Boundaries: Avoid mocking internal service classes; excessive mocks break upon refactoring and produce false positives under mutation testing.
  • API Contract Verification: Validate API contracts using openapi-diff in CI pipelines to prevent accidental backwards-incompatible schema changes.

7. Static Quality Enforcement (Error Prone & NullAway)

Configure Gradle to eliminate nullability defects and unsafe type casts at compile time:

plugins {
  id("net.ltgt.errorprone") version "4.0.1"
}

dependencies {
  errorprone("com.google.errorprone:error_prone_core:2.30.0")
  errorprone("com.uber.nullaway:nullaway:0.11.2")
}

tasks.withType<JavaCompile> {
  options.errorprone {
    check("NullAway", CheckSeverity.ERROR)
    option("NullAway:AnnotatedPackages", "com.acme")
  }
  options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
}

Pairing -Werror with NullAway ensures that null dereference risks and raw type usages are rejected by the compiler before tests or code reviews take place.

8. Claude Code Integration Hooks

Configure local feedback loops in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{ "type": "command", "command": ".claude/hooks/guard-bash.sh" }]
    }],
    "PostToolUse": [{
      "matcher": "Edit|Write",
      "hooks": [{ "type": "command", "command": ".claude/hooks/post-edit.sh" }]
    }],
    "Stop": [{
      "hooks": [{ "type": "command", "command": ".claude/hooks/on-stop.sh" }]
    }]
  }
}

.claude/hooks/guard-bash.sh (Block destructive commands):

#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command' < /dev/stdin)
if echo "$cmd" | grep -Eq 'git push.*--force|rm -rf /|DROP TABLE|flyway clean'; then
  echo "Blocked prohibited command: $cmd" >&2; exit 2
fi

.claude/hooks/post-edit.sh (Automated formatting, compilation, and test-skip detection):

#!/usr/bin/env bash
f=$(jq -r '.tool_input.file_path' < /dev/stdin)
case "$f" in
  *.java) ./gradlew -q spotlessApply compileJava compileTestJava || exit 2 ;;
esac
if grep -q '@Disabled' "$f" 2>/dev/null; then
  echo "@Disabled is prohibited: fix the test instead of ignoring it" >&2; exit 2
fi

.claude/hooks/on-stop.sh (Prevent finishing while checks are failing):

#!/usr/bin/env bash
./gradlew -q check -x pitest 2>&1 | tail -30 >&2 || exit 2

9. Skill Templates

.claude/skills/add-endpoint/SKILL.md:

---
name: add-endpoint
description: Use when adding a new REST endpoint. Produces controller, use case, DTO records, tests, and OpenAPI spec updates following hexagonal architecture.
---
1. Read the relevant spec in docs/specs; list acceptance criteria.
2. Author failing WebMvcTest and use case unit tests first (TDD).
3. Place use case and port under application/; place controller and immutable record DTO under adapter/in/web/.
4. Never return JPA entities directly over the wire; format API errors with ProblemDetail (RFC 9457).
5. Verify `./gradlew check` and `ArchitectureTest` pass cleanly before finishing.
6. Commit in atomic slices: tests, implementation, documentation.

.claude/skills/mutation-triage/SKILL.md:

---
name: mutation-triage
description: Analyzes PIT surviving mutants and writes behavioral tests to kill them.
---
1. Run `./gradlew pitest` and parse build/reports/pitest/mutations.xml.
2. Group surviving mutants by class; identify and document equivalent mutants.
3. For each actionable survivor, author a test that verifies the missing behavior (not internal implementation details).
4. Do not modify production code. Re-run pitest and report the updated mutation score.

Recommended additional skills: flyway-migration (expand/contract migrations), openrewrite-upgrade (automated Spring Boot framework upgrades), add-arch-rule, and security-review.

10. Continuous Integration Pipeline (GitHub Actions)

name: ci
on: [pull_request]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: 21, cache: gradle }
      - run: ./gradlew spotlessCheck check        # Unit tests, ArchUnit, Error Prone
      - run: ./gradlew integrationTest            # Testcontainers suites
      - run: ./gradlew pitest -Pfeatures="+GIT(from[origin/main])"
      - uses: github/codeql-action/init@v3
        with: { languages: java }
      - uses: github/codeql-action/analyze@v3
      - run: ./gradlew dependencyCheckAnalyze     # Or OSV-Scanner
      - uses: gitleaks/gitleaks-action@v2

Nightly workflows run comprehensive mutation suites, stress testing, and dependency updates. All verify jobs are strictly required in GitHub branch protection rules.

11. Spring-Specific AI Pitfalls and Remedies

AI PitfallAutomated Remedy
Field injection with @Autowired, circular bean dependenciesArchUnit rules enforcing constructor injection
Returning JPA entities from controllers, lazy-loading exceptionsDTO record requirement enforced via ArchUnit and unit tests
Misplaced @Transactional annotations, self-invocation bypassesArchUnit rules restricting transactional methods to application layer
N+1 query cascades in relational queries@DataJpaTest with Hibernate query count assertions / datasource-proxy
Outdated Spring Boot 2 patterns (e.g., WebSecurityConfigurerAdapter)Target version in AGENTS.md, -Werror, OpenRewrite recipes
Permissive security configuration (permitAll() sprawl)Security slice tests (@WithMockUser) and strict peer review
Editing historical Flyway migrationsPre-commit hook + CI checksum validation
Green mock tests that fail against real database enginesTestcontainers integration suites + PIT mutation testing
Excessive Lombok boilerplate or runtime reflection magicJava 21 records and sealed interfaces; explicit AGENTS.md rules
Unchecked Optional.get() and unexpected null returnsNullAway + Error Prone compiler enforcement

12. Phased Adoption Roadmap

  1. Foundation: Establish AGENTS.md, configure ./gradlew check, Spotless, and Error Prone with -Werror.
  2. Architecture: Introduce ArchUnit with initial 6–8 layer rules, freezing legacy violations where necessary.
  3. Continuous Verification: Standardize Testcontainers, GitHub Actions CI, CodeQL, and gitleaks scans.
  4. Mutation Quality Gates: Deploy PIT on domain and application layers, stepping thresholds from 60% toward 80%.
  5. Agent Ergonomics: Implement Claude Code local hooks, 3–4 tailored task skills, and dedicated reviewer subagents.
  6. Advanced Testing: Introduce property-based testing with jqwik, contract testing, and Spring Modulith verifications.
  7. Metrics & Feedback: Track change failure rates, mutation score trends, and average PR diff sizes to continually calibrate guardrails.

Related publications