mcpbeat Sign in

Spring Boot Skill for Claude

Use when building, reviewing, testing, securing or configuring a Spring Boot 4 / Framework 7 backend — controllers, services, Spring Data JPA, application.yml, SecurityFilterChain, slice tests. NOT plain modern-Java language work like records or virtual threads (that is `java`); NOT engine-level SQL schema/index/EXPLAIN (that is `postgresdb`).

9k tokens
context cost
the whole folder, loaded on every use
7
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill spring-boot

What comes with it

20 169 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/jpa.md
references/security.md
references/testing.md
scripts/verify.sh

The instruction itself

13 sections, as written by the author

Spring Boot backends (Boot 4 / Framework 7)

A Spring Boot app is **a thin web layer delegating to a transactional service layer over

Spring Data JPA repositories** — wired by constructor injection, configured by typed

@ConfigurationProperties, locked down by a SecurityFilterChain bean. Controllers

validate input and delegate; they never own business logic, transactions, or persistence.

Hold that shape and most "where does this go?" questions answer themselves.

Pinned stack (verify against the project's pom.xml/build.gradle — do not assume):

Spring Boot 4.0 (GA 2025-11-20), Spring Framework 7, Java 17 baseline / Java 25 LTS, Jakarta

EE 11 (jakarta.*, never javax.*), Jackson 3, Spring Security 7, Spring Data JPA /

Hibernate 7, JUnit 5 + Testcontainers, Maven 3.9 / Gradle.

If you are typing WebSecurityConfigurerAdapter, @MockBean, field @Autowired,

authorizeRequests, or javax.persistencestop. Those are the previous generation.

The modern idioms below replace every one of them.

Boundaries

  • Plain Java language work (records, sealed types, virtual threads, streams, pattern

matching) with no Spring -> ../java/SKILL.md.

  • Async Python FastAPI -> ../fastapi/SKILL.md. NestJS/Node ->

../nestjs/SKILL.md. Django -> ../django/SKILL.md.

  • Engine-level SQL: schema/index design, EXPLAIN, partitioning, zero-downtime DDL ->

../postgresdb/SKILL.md (this skill drives the JPA layer above it).

  • Language-agnostic injection/authz/secret theory ->

../secure-coding/SKILL.md.

  • Dockerfile/Compose/CI/CD mechanics -> ../deployment/SKILL.md

(keep only a build note here).

Project layout

Package by feature, not by layer — colocation keeps a change to one feature in one folder.

com.acme.shop
├── order/
│   ├── OrderController.java       // @RestController — web edge
│   ├── OrderService.java          // @Service — @Transactional unit of work
│   ├── OrderRepository.java       // extends JpaRepository<Order, Long>
│   ├── Order.java                 // @Entity (jakarta.persistence)
│   └── dto/CreateOrderRequest.java, OrderResponse.java   // records, never entities
├── config/AppProperties.java      // @ConfigurationProperties record
├── security/SecurityConfig.java   // SecurityFilterChain bean
└── ShopApplication.java           // @SpringBootApplication

Controllers

@RestController + DTO records, @Valid on the body (Bean Validation, jakarta.validation)

so business code can assume valid data, ResponseEntity for 201/Location, a

@RestControllerAdvice for one error envelope. The controller parses, validates, delegates

and maps — any branch with business meaning belongs in the service, where it is transactional

and unit-testable without MVC. Boot 4 adds first-class versioning via a version attribute

on the mapping — one controller serves many versions, no path duplication.

@RestController
@RequestMapping("/api/users")
class UserController {
    private final UserService users;
    UserController(UserService users) { this.users = users; }   // constructor injection

    @PostMapping(version = "1")                                  // Boot 4 API versioning
    ResponseEntity<UserResponse> create(@Valid @RequestBody CreateUserRequest req) {
        UserResponse body = users.create(req);
        URI location = URI.create("/api/users/" + body.id());
        return ResponseEntity.created(location).body(body);     // 201 + Location
    }
}

record CreateUserRequest(@NotBlank String name, @Email String email) {}
record UserResponse(Long id, String name, String email) {}
@RestControllerAdvice
class ApiExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ApiError> onInvalid(MethodArgumentNotValidException e) {
        var details = e.getBindingResult().getFieldErrors().stream()
            .map(f -> f.getField() + ": " + f.getDefaultMessage()).toList();
        return ResponseEntity.badRequest().body(new ApiError("validation_failed", "Invalid request", details));
    }
}
record ApiError(String code, String message, List<String> details) {}

Bad -> Good — never return the entity; it leaks columns and lazy-loads in the serializer:

// Bad: leaks columns; lazy fields blow up in the serializer after the tx closes.
@GetMapping("/{id}") User get(@PathVariable Long id) { return repo.findById(id).orElseThrow(); }
// Good: map to a DTO inside the transactional service.
@GetMapping("/{id}") UserResponse get(@PathVariable Long id) { return users.get(id); }

Service + transactions

Constructor-injected, final fields, @Transactional on the write path, readOnly = true

on queries (lets Hibernate skip dirty checking). @Transactional belongs on service methods,

never on a controller or repository: the transaction must wrap the unit of work, not the HTTP

request or a single query.

@Service
class UserService {
    private final UserRepository repo;
    private final PasswordEncoder encoder;
    UserService(UserRepository repo, PasswordEncoder encoder) { this.repo = repo; this.encoder = encoder; }

    @Transactional
    UserResponse create(CreateUserRequest req) {
        var user = repo.save(new User(req.name(), req.email(), encoder.encode(req.rawPassword())));
        return new UserResponse(user.getId(), user.getName(), user.getEmail());
    }

    @Transactional(readOnly = true)
    UserResponse get(Long id) {
        return repo.findById(id).map(this::toResponse).orElseThrow(() -> new NotFoundException(id));
    }
}

Two traps that produce "my @Transactional isn't rolling back":

  • Self-invocation. Calling this.other() inside the same bean bypasses the proxy, so its

@Transactional is ignored. Split into another bean or accept the outer transaction.

  • Checked exceptions don't roll back by default. Spring rolls back on RuntimeException

only; use @Transactional(rollbackFor = ...) for checked ones.

Bad -> Good — field injection vs constructor:

// Bad: not testable with `new`, hides missing beans until runtime, allows final-less mutation.
@Autowired private UserRepository repo;
// Good:
private final UserRepository repo;
UserService(UserRepository repo) { this.repo = repo; }

JPA persistence

jakarta.persistence imports (never javax). Spring Data gives you derived queries for free

and @Query for the rest; Pageable/Page for paging.

import jakarta.persistence.*;

@Entity @Table(name = "users")
class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
    private String name;
    @Column(unique = true) private String email;
    @OneToMany(mappedBy = "user") private List<Order> orders = new ArrayList<>();
    // getters; protected no-arg ctor for Hibernate
}

interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);                       // derived query
    Page<User> findByNameContaining(String q, Pageable page);       // paginated

    @Query("select u from User u join fetch u.orders where u.id = :id")
    Optional<User> findWithOrders(@Param("id") Long id);            // fetch join kills N+1
}

N+1 symptom: iterating a lazy collection issues one query per parent. Fix with a

join fetch, an @EntityGraph, or @BatchSize. LazyInitializationException means you

touched a lazy field after the transaction (and its Hibernate session) closed — map to a DTO

*inside* the @Transactional service, or fetch eagerly for that path. Relationship/cascade

depth, projections, Specifications, optimistic locking and migration tooling are in

references/jpa.md.

Configuration & profiles

# application.yml — no secrets committed here; import them at boot.
spring:
  config:
    import: "optional:configtree:/run/secrets/"   # mount real secrets at runtime
  datasource:
    url: ${DB_URL}
    username: ${DB_USER}
    password: ${DB_PASSWORD}
app:
  invite-ttl: 24h
  max-orders-per-day: 50
---
spring:
  config:
    activate:
      on-profile: dev
app:
  max-orders-per-day: 5
@ConfigurationProperties(prefix = "app")
record AppProperties(Duration inviteTtl, int maxOrdersPerDay) {}   // typed, validated at startup
// register once: @EnableConfigurationProperties(AppProperties.class) on a @Configuration

Bad -> Good — scattered @Value("${app.max-orders-per-day}") strings vs one injected

AppProperties record. One typed binding beats string keys sprinkled across the codebase and

fails fast on a missing/mistyped key instead of NPE-ing later.

Security

A single SecurityFilterChain bean with the lambda DSL, stateless for token APIs, JWT via the

resource server.

@Configuration
@EnableMethodSecurity                                            // enables @PreAuthorize
class SecurityConfig {
    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
          .csrf(csrf -> csrf.disable())                          // OK: stateless token API, no cookies
          .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
          .authorizeHttpRequests(auth -> auth
              .requestMatchers(HttpMethod.POST, "/api/users").permitAll()
              .requestMatchers("/api/admin/**").hasRole("ADMIN")
              .anyRequest().authenticated())
          .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
        return http.build();
    }

    @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}

Order requestMatchers from most specific to least — the first match wins, so a broad

permitAll placed early opens routes you meant to lock. Full JWT/OAuth2 client, method

security, CORS, and CSRF posture (token vs cookie apps) live in

references/security.md. For the language-agnostic authz/secret

principles behind these rules, see ../secure-coding/SKILL.md.

Testing

Pick the narrowest slice that exercises what you changed — @SpringBootTest only when you

genuinely need the full context:

| Slice | Loads | Use for | Collaborators |

|---|---|---|---|

| @WebMvcTest | web layer + Security + MockMvc | one controller's HTTP contract | @MockitoBean the service |

| @DataJpaTest | JPA + in-memory/TC DB, rolls back per test | repository queries, mappings | real repo, test DB |

| @SpringBootTest | full context | end-to-end / integration | real beans, Testcontainers |

@MockBean/@SpyBean are removed — use @MockitoBean/@MockitoSpyBean from

org.springframework.test.context.bean.override.mockito.

@WebMvcTest(UserController.class)
class UserControllerTest {
    @Autowired MockMvc mvc;
    @MockitoBean UserService users;                              // not @MockBean

    @Test void rejectsBlankName() throws Exception {
        mvc.perform(post("/api/users").contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"\",\"email\":\"[email protected]\"}"))
           .andExpect(status().isBadRequest());
    }
}

Integration DB via Testcontainers + @ServiceConnection (auto-wires connection details, no

@DynamicPropertySource):

@TestConfiguration(proxyBeanMethods = false)
class ContainersConfig {
    @Bean @ServiceConnection
    PostgreSQLContainer<?> postgres() { return new PostgreSQLContainer<>("postgres:17"); }
}

Slice deep dive, container reuse, MockMvcTester/WebTestClient, and the CI gate are in

references/testing.md.

HTTP clients & resilience

Outbound calls: declare an @HttpExchange interface and register it — no manual

RestTemplate/HttpServiceProxyFactory boilerplate.

@HttpExchange("/v1")
interface BillingClient {
    @GetExchange("/invoices/{id}") Invoice invoice(@PathVariable String id);
}
// register: @ImportHttpServices(group = "billing", types = BillingClient.class) on a @Configuration

RestClient is the modern synchronous client for ad-hoc calls. For built-in resilience,

@Retryable and @ConcurrencyLimit are core in Framework 7 — no extra Spring Retry

dependency for the basics.

Anti-patterns

| Anti-pattern | Why it's wrong | Do instead |

|---|---|---|

| Extend WebSecurityConfigurerAdapter | Removed in Security 6/7 | SecurityFilterChain bean + lambda DSL |

| @Autowired on a field | Untestable, hides missing beans till runtime | constructor injection, final fields |

| @Transactional on a @RestController | Tx must wrap the unit of work, not the request | put it on the service method |

| Business branching in the controller | Not transactional, needs MVC to test | move the decision into the @Service |

| Return the @Entity from a controller | Leaks columns, lazy-loads in serializer (LIE) | map to a DTO record inside the tx |

| Request body reaching the service unvalidated | Business code can no longer assume valid data | @Valid + jakarta.validation at the edge |

| Scattered @Value("${...}") config keys | String keys, no validation, fails late | one typed @ConfigurationProperties record |

| Use @MockBean / @SpyBean | Replaced in Boot 4 | @MockitoBean / @MockitoSpyBean |

| import javax.persistence / javax.validation | Jakarta EE 11 baseline | jakarta.* |

| authorizeRequests / antMatchers | Gone in Security 6/7 | authorizeHttpRequests + requestMatchers |

| csrf().disable() with no rationale | Silently opens cookie-session apps | disable only for stateless token APIs; comment why |

| @SpringBootTest for one controller | Slow, loads everything | @WebMvcTest + @MockitoBean |

| One 800-line @Service | Untestable, tangled transactions | split per use case / aggregate |

| catch (Exception e) and echo e.getMessage() | Leaks internals, swallows bugs | @RestControllerAdvice + typed error envelope |

| Serialize a lazy collection after the tx closes | LazyInitializationException / N+1 | fetch join or @EntityGraph, map in-tx |

scripts/verify.sh greps a project for the legacy idioms above (read-only, best effort).

Quick reference

| Task | Idiom |

|---|---|

| Inject a dependency | constructor arg, final field |

| Expose an endpoint | @RestController + @GetMapping/@PostMapping(version=) |

| Validate input | @Valid @RequestBody + jakarta.validation annotations |

| Get by id | repo.findById(id).orElseThrow(...) in a readOnly tx |

| Paginate | Page<T> findBy...(..., Pageable page) |

| Custom query | @Query("select ... join fetch ...") |

| Transaction boundary | @Transactional on the service method |

| Hash a password | PasswordEncoder bean (BCryptPasswordEncoder) |

| Lock down routes | SecurityFilterChain + authorizeHttpRequests/requestMatchers |

| JWT API | oauth2ResourceServer(o -> o.jwt(...)), stateless session |

| Mock a collaborator in a test | @MockitoBean |

| Integration DB | Testcontainers @Bean + @ServiceConnection |

Project grounding

If the repo has a 02-DOCS/ wiki, record stack decisions (Boot version, security posture,

test strategy, migration tool) in 02-DOCS/wiki/stack/spring-boot.md and link it from the

CLAUDE.md Knowledge map. This is recorded, not gated — if there is no 02-DOCS/, skip

silently; you may suggest the project harness if the user wants persistent docs.

How to use it

Copy the folder

Take ericrisco/spring-boot from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.