Files
ai-development-scaffold/.agents/skills/languages/java/SKILL.md
T
james.bland 3f0786f1e7 refactor: agents and skills move to a client-neutral source that renders per client
`.claude/agents/` was the source of truth, which made every role Claude-Code
shaped. Adding a second client meant rewriting each role in that client's syntax
and maintaining both copies — the drift this scaffold exists to prevent, one layer
up.

Roles and skills now live under `.agents/` and render into each registered
client. `.claude/agents/`, `.claude/skills/` and `.codex/agents/` are generated;
`scripts/sync-agent-integrations.py --check` fails on drift and belongs in CI.

The role metadata is portable rather than vendor-named: `reasoning_tier`
(deep/balanced/fast/vision), `capabilities`, `mutation`, `invocation`, and an
optional `preload_skills`. A client manifest maps those to native syntax and must
declare what it cannot express — `codex.yaml` declares `tier_policy: unsupported`
and its adapters say so in the file, rather than the tier silently evaporating and
leaving the repository to believe it was enforced.

The port is behaviour-preserving where it should be and a fix where it should not.
Every instruction body is byte-identical — the whole diff to `.claude/agents/` is
18 added lines and zero deletions. What changed is frontmatter that was missing:

- four agents (`code-reviewer`, `tdd-guardian`, `dependency-audit`, `pr-creator`)
  declared no `tools:` and therefore inherited the ENTIRE tool pool, so three
  review-only agents could edit and write the code they were reviewing. All eight
  now declare capabilities explicitly.
- the six read-only roles gain a non-editing permission mode, so the constraint is
  enforced by the client rather than by the prompt asking nicely.
- `mutation` is now explicit, which records the two roles that genuinely need to
  write: `pr-creator` (external-write — it pushes a branch and opens a PR) and
  `dependency-audit` (workspace-write — package managers rewrite lockfiles).

`pr-creator` keeps `shell` because opening a PR needs it, but it is now the only
agent here with a write mutation and a declared reason for it, instead of one of
four with unlimited access by omission.
2026-09-19 17:27:26 -04:00

19 KiB

name, description
name description
java-development Java development patterns with Spring Boot, JUnit 5, and modern Java practices. Use when writing Java code.

Java Development Skill

Project Structure (Spring Boot)

project/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/mycompany/myapp/
│   │   │       ├── MyApplication.java
│   │   │       ├── domain/
│   │   │       │   ├── User.java
│   │   │       │   └── Order.java
│   │   │       ├── repository/
│   │   │       │   └── UserRepository.java
│   │   │       ├── service/
│   │   │       │   └── UserService.java
│   │   │       ├── controller/
│   │   │       │   └── UserController.java
│   │   │       └── config/
│   │   │           └── SecurityConfig.java
│   │   └── resources/
│   │       ├── application.yml
│   │       └── application-test.yml
│   └── test/
│       └── java/
│           └── com/mycompany/myapp/
│               ├── service/
│               │   └── UserServiceTest.java
│               └── controller/
│                   └── UserControllerTest.java
├── pom.xml
└── README.md

Testing with JUnit 5

Unit Tests with Mockito

package com.mycompany.myapp.service;

import com.mycompany.myapp.domain.User;
import com.mycompany.myapp.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Nested
    @DisplayName("createUser")
    class CreateUser {

        @Test
        @DisplayName("should create user with valid data")
        void shouldCreateUserWithValidData() {
            // Given
            var request = UserFixtures.createUserRequest();
            var savedUser = UserFixtures.user();
            when(userRepository.save(any(User.class))).thenReturn(savedUser);

            // When
            var result = userService.createUser(request);

            // Then
            assertThat(result.getId()).isNotNull();
            assertThat(result.getEmail()).isEqualTo(request.getEmail());
            verify(userRepository).save(any(User.class));
        }

        @Test
        @DisplayName("should throw exception for duplicate email")
        void shouldThrowExceptionForDuplicateEmail() {
            // Given
            var request = UserFixtures.createUserRequest();
            when(userRepository.existsByEmail(request.getEmail())).thenReturn(true);

            // When/Then
            assertThatThrownBy(() -> userService.createUser(request))
                .isInstanceOf(DuplicateEmailException.class)
                .hasMessageContaining(request.getEmail());
        }
    }

    @Nested
    @DisplayName("getUserById")
    class GetUserById {

        @Test
        @DisplayName("should return user when found")
        void shouldReturnUserWhenFound() {
            // Given
            var user = UserFixtures.user();
            when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));

            // When
            var result = userService.getUserById(user.getId());

            // Then
            assertThat(result).isPresent();
            assertThat(result.get().getId()).isEqualTo(user.getId());
        }

        @Test
        @DisplayName("should return empty when user not found")
        void shouldReturnEmptyWhenNotFound() {
            // Given
            when(userRepository.findById(any())).thenReturn(Optional.empty());

            // When
            var result = userService.getUserById("unknown-id");

            // Then
            assertThat(result).isEmpty();
        }
    }
}

Test Fixtures

package com.mycompany.myapp.fixtures;

import com.mycompany.myapp.domain.User;
import com.mycompany.myapp.dto.CreateUserRequest;

import java.time.Instant;
import java.util.UUID;

public final class UserFixtures {

    private UserFixtures() {}

    public static User user() {
        return user(builder -> {});
    }

    public static User user(UserCustomizer customizer) {
        var builder = User.builder()
            .id(UUID.randomUUID().toString())
            .email("[email protected]")
            .name("Test User")
            .role(Role.USER)
            .createdAt(Instant.now())
            .updatedAt(Instant.now());

        customizer.customize(builder);
        return builder.build();
    }

    public static CreateUserRequest createUserRequest() {
        return createUserRequest(builder -> {});
    }

    public static CreateUserRequest createUserRequest(CreateUserRequestCustomizer customizer) {
        var builder = CreateUserRequest.builder()
            .email("[email protected]")
            .name("New User")
            .password("SecurePass123!");

        customizer.customize(builder);
        return builder.build();
    }

    @FunctionalInterface
    public interface UserCustomizer {
        void customize(User.UserBuilder builder);
    }

    @FunctionalInterface
    public interface CreateUserRequestCustomizer {
        void customize(CreateUserRequest.CreateUserRequestBuilder builder);
    }
}

// Usage in tests
var adminUser = UserFixtures.user(builder -> builder.role(Role.ADMIN));
var customRequest = UserFixtures.createUserRequest(builder ->
    builder.email("[email protected]"));

Parameterized Tests

package com.mycompany.myapp.validation;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.*;

class EmailValidatorTest {

    private final EmailValidator validator = new EmailValidator();

    @ParameterizedTest
    @DisplayName("should accept valid emails")
    @ValueSource(strings = {
        "[email protected]",
        "[email protected]",
        "[email protected]"
    })
    void shouldAcceptValidEmails(String email) {
        assertThat(validator.isValid(email)).isTrue();
    }

    @ParameterizedTest
    @DisplayName("should reject invalid emails")
    @ValueSource(strings = {
        "invalid",
        "missing@domain",
        "@nodomain.com",
        "spaces [email protected]"
    })
    void shouldRejectInvalidEmails(String email) {
        assertThat(validator.isValid(email)).isFalse();
    }

    @ParameterizedTest
    @DisplayName("should reject null and empty emails")
    @NullAndEmptySource
    void shouldRejectNullAndEmpty(String email) {
        assertThat(validator.isValid(email)).isFalse();
    }

    @ParameterizedTest
    @DisplayName("should validate password strength")
    @CsvSource({
        "password,false",
        "Password1,false",
        "Password1!,true",
        "P@ssw0rd,true"
    })
    void shouldValidatePasswordStrength(String password, boolean expected) {
        assertThat(validator.isStrongPassword(password)).isEqualTo(expected);
    }
}

Integration Tests with Spring

package com.mycompany.myapp.controller;

import com.mycompany.myapp.fixtures.UserFixtures;
import com.mycompany.myapp.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    @DisplayName("POST /api/users creates user and returns 201")
    void createUserReturnsCreated() throws Exception {
        var request = """
            {
                "email": "[email protected]",
                "name": "New User",
                "password": "SecurePass123!"
            }
            """;

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(request))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").isNotEmpty())
            .andExpect(jsonPath("$.email").value("[email protected]"))
            .andExpect(jsonPath("$.name").value("New User"));
    }

    @Test
    @DisplayName("GET /api/users/{id} returns user when found")
    void getUserReturnsUserWhenFound() throws Exception {
        var user = userRepository.save(UserFixtures.user());

        mockMvc.perform(get("/api/users/{id}", user.getId()))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(user.getId()))
            .andExpect(jsonPath("$.email").value(user.getEmail()));
    }

    @Test
    @DisplayName("GET /api/users/{id} returns 404 when not found")
    void getUserReturns404WhenNotFound() throws Exception {
        mockMvc.perform(get("/api/users/{id}", "unknown-id"))
            .andExpect(status().isNotFound());
    }
}

Domain Models with Records

package com.mycompany.myapp.domain;

import jakarta.validation.constraints.*;
import java.time.Instant;

// Immutable domain model using records (Java 17+)
public record User(
    String id,
    @NotBlank @Email String email,
    @NotBlank @Size(max = 100) String name,
    Role role,
    Instant createdAt,
    Instant updatedAt
) {
    // Compact constructor for validation
    public User {
        if (email != null) {
            email = email.toLowerCase().trim();
        }
    }

    // Static factory methods
    public static User create(String email, String name) {
        var now = Instant.now();
        return new User(
            UUID.randomUUID().toString(),
            email,
            name,
            Role.USER,
            now,
            now
        );
    }

    // Wither methods for immutable updates
    public User withName(String newName) {
        return new User(id, email, newName, role, createdAt, Instant.now());
    }

    public User withRole(Role newRole) {
        return new User(id, email, name, newRole, createdAt, Instant.now());
    }
}

// For mutable entities (JPA)
@Entity
@Table(name = "users")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String id;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String name;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Column(name = "created_at")
    private Instant createdAt;

    @Column(name = "updated_at")
    private Instant updatedAt;

    @PrePersist
    void onCreate() {
        createdAt = Instant.now();
        updatedAt = createdAt;
    }

    @PreUpdate
    void onUpdate() {
        updatedAt = Instant.now();
    }
}

Service Layer

package com.mycompany.myapp.service;

import com.mycompany.myapp.domain.User;
import com.mycompany.myapp.dto.CreateUserRequest;
import com.mycompany.myapp.exception.DuplicateEmailException;
import com.mycompany.myapp.exception.UserNotFoundException;
import com.mycompany.myapp.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public User createUser(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateEmailException(request.getEmail());
        }

        var user = User.create(
            request.getEmail(),
            request.getName()
        );

        return userRepository.save(user);
    }

    public Optional<User> getUserById(String id) {
        return userRepository.findById(id);
    }

    public User getUserByIdOrThrow(String id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    @Transactional
    public User updateUser(String id, UpdateUserRequest request) {
        var user = getUserByIdOrThrow(id);

        var updatedUser = user
            .withName(request.getName())
            .withRole(request.getRole());

        return userRepository.save(updatedUser);
    }

    @Transactional
    public void deleteUser(String id) {
        if (!userRepository.existsById(id)) {
            throw new UserNotFoundException(id);
        }
        userRepository.deleteById(id);
    }
}

REST Controllers

package com.mycompany.myapp.controller;

import com.mycompany.myapp.dto.*;
import com.mycompany.myapp.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    private final UserMapper userMapper;

    @GetMapping
    public List<UserResponse> listUsers() {
        return userService.getAllUsers().stream()
            .map(userMapper::toResponse)
            .toList();
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable String id) {
        return userService.getUserById(id)
            .map(userMapper::toResponse)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
        var user = userService.createUser(request);
        return userMapper.toResponse(user);
    }

    @PutMapping("/{id}")
    public UserResponse updateUser(
            @PathVariable String id,
            @Valid @RequestBody UpdateUserRequest request) {
        var user = userService.updateUser(id, request);
        return userMapper.toResponse(user);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteUser(@PathVariable String id) {
        userService.deleteUser(id);
    }
}

Exception Handling

package com.mycompany.myapp.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ProblemDetail handleUserNotFound(UserNotFoundException ex) {
        var problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            ex.getMessage()
        );
        problem.setTitle("User Not Found");
        problem.setType(URI.create("https://api.myapp.com/errors/user-not-found"));
        return problem;
    }

    @ExceptionHandler(DuplicateEmailException.class)
    public ProblemDetail handleDuplicateEmail(DuplicateEmailException ex) {
        var problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.CONFLICT,
            ex.getMessage()
        );
        problem.setTitle("Duplicate Email");
        problem.setType(URI.create("https://api.myapp.com/errors/duplicate-email"));
        return problem;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        var problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            "Validation failed"
        );
        problem.setTitle("Validation Error");

        var errors = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> new ValidationError(e.getField(), e.getDefaultMessage()))
            .toList();

        problem.setProperty("errors", errors);
        return problem;
    }

    record ValidationError(String field, String message) {}
}

Commands

# Maven
mvn clean install                      # Build and test
mvn test                               # Run tests only
mvn verify                             # Run integration tests
mvn spring-boot:run                    # Run application
mvn jacoco:report                      # Generate coverage report

# Gradle
./gradlew build                        # Build and test
./gradlew test                         # Run tests only
./gradlew integrationTest              # Run integration tests
./gradlew bootRun                      # Run application
./gradlew jacocoTestReport             # Generate coverage report

# Common flags
-DskipTests                            # Skip tests
-Dspring.profiles.active=test          # Set profile

Dependencies (pom.xml)

<dependencies>
    <!-- Spring Boot -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>

    <!-- Testing -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>