Spring Boot skill for Claude Code
Typed config, honest JPA and fast test slices. Claude writes Spring Boot code with constructor injection, validated configuration records, and explicit JPA fetch plans instead of relying on open-session-in-view. Errors come back as ProblemDetail, tests use the narrowest slice with Testcontainers rather than H2, and Actuator exposes health probes and metrics without leaking env or heap dumps.
- name
spring-boot - size178 lines · ≈1,950 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and reviews Spring Boot 3.x services (with notes for Boot 4) - typed @ConfigurationProperties, profiles, Spring Data JPA without N+1 or open-session-in-view traps, transactions, Bean Validation, ProblemDetail error handling, test slices with Testcontainers, Actuator health and metrics, virtual threads, and graceful shutdown. Use when writing Spring controllers, services or repositories, fixing slow JPA queries or LazyInitializationException, configuring Actuator, or speeding up a slow Spring test suite. Try asking
- Fix the LazyInitializationException in our order endpoint properly
- Set up Actuator health probes for Kubernetes on this Spring app
- Our Spring test suite takes 12 minutes. Make it faster
Use it when
- Writing Spring controllers, services or Spring Data repositories
- JPA queries are slow or throw lazy-loading exceptions
- Configuring profiles, properties, Actuator or shutdown
- Restructuring a slow or flaky Spring test suite
Skip it when
- The question is core Java language features — use java-modern
- You need generic SQL plan analysis — use sql-query-tuning
How to check the result
The build passes with slice and Testcontainers tests, SQL logs show the expected statement count per endpoint, and readiness is UP while sensitive Actuator endpoints return 404.
The common mistake it prevents
Turning open-in-view back on to silence LazyInitializationException, which hides N+1 queries and holds connections for the whole request.
A healthy Spring Boot service binds configuration into validated, typed objects, keeps
JPA access inside explicit transactions with fetch plans chosen per use case, returns
RFC 9457 ProblemDetail errors, tests each layer with the narrowest slice, and exposes
only safe Actuator endpoints.
Workflow
- [ ] 1. Read build file: Boot version, Java version, starters, test deps
- [ ] 2. Read application.yml/properties per profile and the main config classes
- [ ] 3. Implement: controller (HTTP only) -> service (@Transactional) -> repository
- [ ] 4. Choose fetch strategy per query; check generated SQL
- [ ] 5. Validation + @RestControllerAdvice with ProblemDetail
- [ ] 6. Tests: slices for web/JPA, @SpringBootTest + Testcontainers for flows
- [ ] 7. Actuator, shutdown, and production profile review
Versions
Boot 3.x requires Java 17+ and Jakarta EE (jakarta.* imports). Boot 3.2 added virtual
threads (spring.threads.virtual.enabled=true on Java 21), RestClient, and
JdbcClient. Boot 3.4 made graceful shutdown the default and deprecated @MockBean in
favor of @MockitoBean/@MockitoSpyBean (Spring Framework 6.2). Boot 4.0 (Nov 2025)
moves to Spring Framework 7, Jakarta EE 11, and Jackson 3, and modularizes
auto-configuration; when upgrading, first get to the latest 3.5.x with zero deprecation
warnings, then follow the 4.0 migration guide.
Configuration
@ConfigurationProperties(prefix = "payments")
@Validated
public record PaymentsProperties(
@NotBlank String apiBaseUrl,
@NotNull Duration timeout,
@Min(1) @Max(10) int maxRetries) {}
Enable with @ConfigurationPropertiesScan on the application class. Prefer this over
scattered @Value. Profiles: application.yml holds defaults; application-prod.yml
overrides; activate via SPRING_PROFILES_ACTIVE=prod. Secrets come from environment
variables or a secrets backend, never committed YAML. Relaxed binding maps
PAYMENTS_API_BASE_URL to payments.api-base-url.
JPA without the traps
Set these first in every service:
spring:
jpa:
open-in-view: false # default true hides lazy loading in views and holds DB connections
hibernate:
ddl-auto: validate # schema comes from Flyway/Liquibase, never Hibernate
properties:
hibernate.jdbc.batch_size: 50
hibernate.order_inserts: true
| Problem | Fix |
|---|---|
| N+1 when serializing a collection | @EntityGraph(attributePaths = {"items"}) on the repository method, or join fetch in JPQL |
LazyInitializationException | Fetch what the use case needs inside the transaction; map to DTOs before returning; never enable open-in-view to hide it |
| Paging + collection fetch join warning (HHH90003004) | Page IDs first, then fetch entities with collections by ID list |
| Read-only queries | @Transactional(readOnly = true) on the service; DTO projections (records) via JPQL constructor expressions or interface projections |
| Bulk updates | @Modifying @Query("update ...") with clearAutomatically = true |
| Lost updates | @Version field for optimistic locking; handle ObjectOptimisticLockingFailureException as 409 |
FetchType.EAGERon@ManyToOneis the default; setfetch = FetchType.LAZYon every@ManyToOne/@OneToOneand fetch explicitly.- Do not return entities from controllers; return records/DTOs. Entities with bidirectional relations recurse in Jackson and leak columns.
- Log SQL during development:
logging.level.org.hibernate.SQL=debugand count queries, or use a datasource proxy in tests to assert query counts.
Transactions
@Transactionalon public service methods only. Self-invocation (this.other()) bypasses the proxy, so the annotation onotherdoes nothing.- Checked exceptions do not roll back by default; use
rollbackForor unchecked exceptions. - Keep remote calls (HTTP, messaging) out of transactions; publish after commit with
@TransactionalEventListener(phase = AFTER_COMMIT)or an outbox table.
Validation and errors
@RestController
@RequestMapping("/v1/orders")
class OrderController {
private final OrderService service;
OrderController(OrderService service) { this.service = service; } // constructor injection only
@PostMapping
ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest req, UriComponentsBuilder uri) {
var order = service.create(req);
return ResponseEntity.created(uri.path("/v1/orders/{id}").build(order.id())).body(order);
}
}
@RestControllerAdvice
class ApiErrors extends ResponseEntityExceptionHandler { // handles validation as ProblemDetail
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail notFound(OrderNotFoundException ex) {
var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://api.example.com/problems/order-not-found"));
return pd;
}
}
Set spring.mvc.problemdetails.enabled=true so framework exceptions render as
application/problem+json. Validation failures return 400 by default; customize
handleMethodArgumentNotValid to list field errors.
Test slices
| Test target | Annotation | Loads |
|---|---|---|
| Controller mapping, validation, JSON | @WebMvcTest(OrderController.class) + MockMvc (or MockMvcTester in 6.2+) | Web layer only; mock services with @MockitoBean |
| Repository queries | @DataJpaTest + Testcontainers Postgres | JPA, Flyway, no web |
| JSON mapping | @JsonTest | ObjectMapper only |
| Full flow | @SpringBootTest(webEnvironment = RANDOM_PORT) | Everything |
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:17");
}
@ServiceConnection (Boot 3.1+) wires container URLs without @DynamicPropertySource.
Avoid H2 as a stand-in for Postgres; dialect differences hide real bugs. Every distinct
combination of @MockitoBeans creates a new application context; keep mocks consistent
across tests to reuse cached contexts and keep the suite fast.
Actuator and runtime
management:
endpoints.web.exposure.include: health,info,prometheus
endpoint.health.probes.enabled: true # /actuator/health/liveness and /readiness
endpoint.health.show-details: when-authorized
server:
shutdown: graceful # default since 3.4
spring.lifecycle.timeout-per-shutdown-phase: 25s
Never expose env, heapdump, threaddump, loggers publicly; put management on a
separate port (management.server.port) not routed by the ingress. Liveness must not
check downstream dependencies (a DB outage would restart every pod); readiness may.
Gotchas
- Field injection (
@Autowiredon fields) hides dependencies and breaks tests; use constructors. @Asyncand@Cacheableshare the self-invocation proxy trap with@Transactional.spring.jpa.hibernate.ddl-auto=updatein production silently diverges schemas.- Virtual threads plus
synchronizedblocks around I/O pin carrier threads on Java 21-23; Java 24 removed most pinning. Size DB pools explicitly; virtual threads do not raise database capacity. - Lombok
@Dataon entities breaksequals/hashCodewith lazy proxies; write them by ID.
Verification
./mvnw verifyor./gradlew checkpasses with slice and integration tests.- SQL log for each touched endpoint shows the expected number of statements.
curl localhost:8080/actuator/health/readinessreturns UP; sensitive endpoints 404.- Send SIGTERM during a request: it completes, and the app logs graceful shutdown.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.