PACE with Java (Spring Boot)
This tutorial walks through setting up PACE for a Java Spring Boot project. By the end, FORGE will write Java code, GATE will run your JUnit 5 test suite, and SENTINEL will review for Java-specific security issues.
Prerequisites
- Java 17 or 21
- Maven 3.9+ or Gradle 8+
- An existing Spring Boot project with at least a minimal test suite
- An API key for your LLM provider
Project layout assumed by this tutorial
my-service/├── pace/ ← PACE subdirectory│ └── pace.config.yaml├── src/│ ├── main/java/com/acme/│ └── test/java/com/acme/├── pom.xml ← or build.gradle└── .pace/1 — Install PACE
# From your repo rootgit clone https://github.com/pace-framework-org/pace-framework-starter pacecd pacepython -m venv .venv && source .venv/bin/activatepip install PyYAML jsonschema anthropic2 — Configure for Java
Edit pace/pace.config.yaml:
framework_version: "1.0"
product: name: "Acme Service" description: > A Spring Boot REST API for the Acme platform. Serves mobile and web clients. Handles orders, inventory, and payment processing. github_org: "acme-corp"
sprint: duration_days: 14
source: dirs: - name: "api" path: "src/main/java/com/acme/" language: "Java" description: "Spring Boot application, domain models, and REST controllers" - name: "tests" path: "src/test/java/com/acme/" language: "Java" description: "JUnit 5 unit and integration tests"
tech: primary_language: "Java 21" ci_system: "GitHub Actions" test_command: "mvn -q test" # or: ./gradlew test --quiet build_command: "mvn -q compile" # or: ./gradlew compileJava
platform: type: local # switch to github after verifying locally
llm: provider: anthropic model: claude-sonnet-4-6Maven vs Gradle test commands
| Build tool | test_command | build_command |
|---|---|---|
| Maven | mvn -q test | mvn -q compile |
| Maven (skip integration tests) | mvn -q test -Dexclude="**/*IT.java" | mvn -q compile |
| Gradle | ./gradlew test --quiet | ./gradlew compileJava |
| Gradle (single module) | ./gradlew :api:test --quiet | ./gradlew :api:compileJava |
3 — Set credentials
export ANTHROPIC_API_KEY="sk-ant-..."4 — Write a Java-focused sprint plan
Create plan.yaml at your repo root:
release: v1.0
stories: - id: story-1 title: "Product JPA entity and Spring Data repository" status: pending acceptance_criteria: - "Product entity has id, name, price, and stockQuantity fields" - "ProductRepository extends JpaRepository<Product, Long>" - "ProductRepositoryTest covers findByName and save round-trip" - "mvn test exits 0" out_of_scope: - "REST endpoints" - "Category associations"
- id: story-2 title: "ProductCatalogService with validation" status: pending acceptance_criteria: - "createProduct() throws IllegalArgumentException for negative price" - "updateStock() throws InsufficientStockException when quantity < 0" - "Service layer is unit-tested with Mockito (no database)" - "mvn test exits 0"
- id: story-3 title: "ProductController with GET/POST/PUT endpoints" status: pending acceptance_criteria: - "GET /products returns 200 with list" - "POST /products returns 201 with Location header" - "PUT /products/{id} returns 200 or 404" - "Integration tests use @SpringBootTest and MockMvc" - "mvn test exits 0"5 — Run Day 1
cd pacepython pace/orchestrator.py --day 1What FORGE does with Java
FORGE’s tool-calling loop will:
- Read existing source files under
src/main/java/com/acme/andsrc/test/java/com/acme/ - Understand the package structure and Spring conventions
- Write new
.javafiles using JPA annotations, Spring Data interfaces, and JUnit 5 patterns - Run
mvn -q compile(build_command) to catch syntax errors before running tests - Run
mvn -q testand read failures to self-correct
A typical FORGE run for Day 1:
[FORGE] Reading src/main/java/com/acme/AcmeServiceApplication.java ...[FORGE] Reading pom.xml ...[FORGE] Writing src/main/java/com/acme/catalog/domain/Product.java ...[FORGE] Writing src/main/java/com/acme/catalog/repository/ProductRepository.java ...[FORGE] Writing src/test/java/com/acme/catalog/repository/ProductRepositoryTest.java ...[FORGE] Running build: mvn -q compile ...[FORGE] Running tests: mvn -q test ...[FORGE] All tests pass. Calling complete_handoff.What SENTINEL checks for Java
SENTINEL applies Java-specific checks including:
- Injection risks: SQL injection via native queries, SpEL injection in
@Query - Deserialization: unsafe use of
ObjectInputStream,XmlDecoder - Secrets in source: hardcoded passwords in
application.propertiesor@Value - Dependency vulnerabilities: flags commonly vulnerable versions (Log4Shell, Spring4Shell patterns)
- JWT handling: weak signing algorithms, missing expiry validation
6 — Handling Maven multi-module projects
For multi-module projects, point PACE at the specific module you are sprinting on:
source: dirs: - name: "catalog-service" path: "catalog-service/src/main/java/" language: "Java" description: "Catalog bounded context — products, categories, pricing"
tech: test_command: "mvn -q test -pl catalog-service" build_command: "mvn -q compile -pl catalog-service -am"7 — Example: switching to GitHub platform
Once your local runs succeed, switch to GitHub to get CI polling and automatic PRs:
platform: ci: githubexport GITHUB_TOKEN="ghp_..."export GITHUB_REPOSITORY="acme-corp/acme-service"pip install PyGithubSee Switch Platform for the full credential reference.
8 — FORGE hints for Java
The most effective way to improve FORGE’s output is to describe your team’s conventions in product.description. FORGE reads this before writing any file.
Useful hints for Spring Boot:
product: description: > A Spring Boot REST API. Use constructor injection everywhere — never @Autowired field injection. All DTOs are Java records. Domain entities use @Entity with JPA annotations. Business logic in @Service classes; controllers call services, services call repositories. Use @ExtendWith(MockitoExtension.class) with @Mock and @InjectMocks for unit tests. Follow Google Java Style Guide, 120 char limit. All new code must pass Checkstyle before tests run.9 — Test coverage (JaCoCo)
Add JaCoCo to pom.xml:
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.11</version> <executions> <execution> <goals><goal>prepare-agent</goal></goals> </execution> <execution> <id>check</id> <goals><goal>check</goal></goals> <configuration> <rules> <rule> <limits> <limit> <counter>LINE</counter> <value>COVEREDRATIO</value> <minimum>0.80</minimum> </limit> </limits> </rule> </rules> </configuration> </execution> </executions></plugin>Switch test_command to mvn -q verify so JaCoCo’s check goal runs after tests:
tech: test_command: "mvn -q verify"For Gradle, add the JaCoCo plugin and jacocoTestCoverageVerification task.
10 — Linter and formatter (Spotless)
Use Spotless for consistent formatting and Checkstyle for style rules:
<plugin> <groupId>com.diffplug.spotless</groupId> <artifactId>spotless-maven-plugin</artifactId> <version>2.43.0</version> <configuration> <java> <googleJavaFormat/> </java> </configuration></plugin>Set build_command to run the linter before tests:
tech: build_command: "mvn -q spotless:check" test_command: "mvn -q verify"Include the expectation in product.description so FORGE generates clean code from the start: "All code must pass Spotless with Google Java Format style."
11 — Database testing
H2 in-memory (fast unit tests)
Add H2 to test scope in pom.xml:
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope></dependency>Use @DataJpaTest for repository tests — Spring Boot auto-configures H2:
@DataJpaTestclass ProductRepositoryTest { @Autowired ProductRepository repository;
@Test void saveAndRetrieve() { var saved = repository.save(new Product("Widget", 9.99)); assertThat(repository.findById(saved.getId())).isPresent(); }}Tell FORGE about the pattern:
product: description: > Repository tests use @DataJpaTest with H2. Never use the production PostgreSQL URL in tests. Service tests use @ExtendWith(MockitoExtension.class) with mocked repositories — no Spring context.Testcontainers (real PostgreSQL)
For integration tests that need real PostgreSQL behaviour:
@SpringBootTest@Testcontainersclass OrderIntegrationTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource static void configure(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); }}Mark with @Tag("integration") and exclude from the default test command:
tech: test_command: "mvn -q test -Dexclude='**/*IT.java,**/*IntegrationTest.java'"12 — Mock patterns (Mockito)
@ExtendWith(MockitoExtension.class)class ProductCatalogServiceTest { @Mock ProductRepository repository; @InjectMocks ProductCatalogService service;
@Test void throwsForNegativePrice() { assertThrows(IllegalArgumentException.class, () -> service.createProduct("Widget", -1.0)); verifyNoInteractions(repository); }}FORGE hint: "Use @ExtendWith(MockitoExtension.class), not @RunWith(MockitoJUnitRunner.class). Always use @InjectMocks for the class under test."
13 — Multi-day sprint (Day 2 builds on Day 1)
After Day 1 ships, SCRIBE records the new classes in .pace/context/engineering.md. Day 2’s FORGE reads this before writing:
[FORGE] Reading .pace/context/engineering.md ...[FORGE] Found: com.acme.catalog.domain.Product (JPA entity, ProductRepository extends JpaRepository)[FORGE] Writing com.acme.catalog.service.ProductCatalogService ...[FORGE] Writing src/test/java/com/acme/catalog/service/ProductCatalogServiceTest.java ...Story-2 acceptance criteria can safely reference Product and ProductRepository by name — FORGE already knows where they live.
14 — Advisory clearance story
After several sprint days, add a clearance story:
- id: story-5 title: "Security and DevOps advisory clearance" status: pending acceptance_criteria: - "All open SENTINEL advisories resolved or escalated with written justification" - "All open CONDUIT advisories resolved or escalated" - "mvn verify exits 0 after any remediation changes"Common Java SENTINEL advisories that accumulate:
@QuerywithnativeQuery = trueand string concatenation — fix: use parameterised?1placeholdersObjectInputStream.readObject()without class filtering — fix: useValidatingObjectInputStream- Hardcoded passwords in
application.properties— fix: use${ENV_VAR}placeholders
15 — CI build caching
Cache Maven’s local repository to avoid re-downloading dependencies on every PACE run:
- uses: actions/cache@v4 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-maven-For Gradle:
- uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}16 — Windows notes
On Windows, use the Maven wrapper (mvnw.cmd) or Gradle wrapper (gradlew.bat) instead of the bare command:
tech: test_command: "mvnw.cmd -q test" build_command: "mvnw.cmd -q spotless:check"Java and the JDK toolchain work natively on Windows — no WSL required.
Common issues
mvn: command not found
PACE runs test_command from the repo root. If Maven is not on PATH inside the pace/ virtualenv, use the Maven wrapper: test_command: "../mvnw -q test" (adjust path relative to repo root).
Gradle daemon conflicts
If FORGE runs the Gradle daemon and your shell has a separate daemon running, use --no-daemon: test_command: "./gradlew test --quiet --no-daemon".
Test output too verbose for GATE
GATE reads the full test output. With Surefire, add -Dsurefire.failIfNoSpecifiedTests=false and ensure <reportFormat>plain</reportFormat> in your POM for clean failure messages.
FORGE writes to wrong package
If your base package is not com.acme, update source.dirs[].path and source.dirs[].description to reflect your actual package structure so SCRIBE and FORGE understand the layout.