---
name: spring-boot
description: "Use when building, reviewing, testing, securing, or configuring a Spring Boot 4 / Spring Framework 7 backend — controllers, services, Spring Data JPA, application.yml, SecurityFilterChain, slice tests. Triggers: 'add a REST endpoint', 'wire up @Service and @Repository', 'my @Transactional isn't rolling back', 'LazyInitializationException after the session closed', 'N+1 select on a lazy collection', '@WebMvcTest + mock the service', 'migrate WebSecurityConfigurerAdapter', 'monta la seguridad con SecurityFilterChain', 'per què surt LazyInitializationException', a file importing org.springframework.boot.*, a pom.xml with spring-boot-starter-web, or an application.yml. NOT plain modern-Java language work like records/streams/virtual threads (that is java); NOT engine-level SQL schema/index/EXPLAIN (that is postgresdb); NOT container/CI mechanics (that is deployment)."
tags: [java, spring, jpa, security, backend]
recommends: [java, postgresdb, secure-coding, deployment]
origin: risco
---

# 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.persistence` — **stop**. Those are the previous generation.
The modern idioms below replace every one of them.

## When to use

- Writing or reviewing any `@RestController`, `@Service`, `@Repository`, `@Component`,
  `@Configuration`, Spring Data JPA `@Entity`, or repository interface.
- JPA persistence: entity mapping, `JpaRepository`, derived queries, `@Query`,
  `@Transactional` boundaries, fetch strategy / N+1, pagination.
- Spring Security: `SecurityFilterChain`, method security, JWT resource server, OAuth2
  client, password encoding, CORS.
- Configuration & profiles: `application.yml`, `@ConfigurationProperties`, `@Profile`,
  externalized secrets, `spring.config.import`.
- Testing: `@SpringBootTest` vs slices (`@WebMvcTest`, `@DataJpaTest`), `@MockitoBean`,
  `MockMvc`, Testcontainers + `@ServiceConnection`.

## When NOT to use

- 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`.
- 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).

## Decision rules

1. **Constructor injection only — final fields, no `@Autowired` on fields.** A class whose
   collaborators are constructor args is testable with `new` and fails fast on a missing bean.
2. **Controller stays thin: parse, validate, delegate, map.** Any branch with business
   meaning belongs in the service, where it is transactional and unit-testable without MVC.
3. **`@Transactional` lives 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.
4. **Expose DTO records in and out — never the `@Entity`.** Returning an entity leaks columns
   and triggers lazy loads inside the JSON serializer (the classic `LazyInitializationException`).
5. **Typed `@ConfigurationProperties` record over scattered `@Value`.** One validated binding
   beats string keys sprinkled across the codebase and gives you a fail-fast startup.
6. **Security is a `SecurityFilterChain` bean with the lambda DSL** — `authorizeHttpRequests`
   + `requestMatchers`. `WebSecurityConfigurerAdapter`, `authorizeRequests`, `antMatchers` are gone.
7. **Reach for the narrowest test slice first.** `@WebMvcTest` for a controller,
   `@DataJpaTest` for a repository; `@SpringBootTest` only when you genuinely need the full context.
8. **Validate at the edge with `@Valid` + Bean Validation (`jakarta.validation`).** Reject
   bad input before it reaches the service so business code assumes valid data.

## Project layout

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

```text
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, `ResponseEntity` for 201/`Location`,
a `@RestControllerAdvice` for one error envelope. Boot 4 adds first-class versioning via a
`version` attribute on the mapping — one controller serves many versions, no path duplication.

```java
@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) {}
```

```java
@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:

```java
// 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).

```java
@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:

```java
// 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.

```java
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`](references/jpa.md).

## Configuration & profiles

```yaml
# 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
```

```java
@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. Typed binding fails fast on a missing/mistyped key instead of NPE-ing later.

## Security

A single `SecurityFilterChain` bean, stateless for token APIs, JWT via the resource server.

```java
@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`](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:

| 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`.

```java
@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\":\"a@b.co\"}"))
           .andExpect(status().isBadRequest());
    }
}
```

Integration DB via Testcontainers + `@ServiceConnection` (auto-wires connection details, no
`@DynamicPropertySource`):

```java
@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`](references/testing.md).

## HTTP clients & resilience

Outbound calls: declare an `@HttpExchange` interface and register it — no manual
`RestTemplate`/`HttpServiceProxyFactory` boilerplate.

```java
@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 / rationalizations -> STOP

| You are about to... | 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 |
| Return the `@Entity` from a controller | Leaks columns, lazy-loads in serializer (LIE) | map to a DTO record inside the tx |
| 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 |

## 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.

## See also

- `../java/SKILL.md` — the language underneath (records, virtual threads, streams).
- `../postgresdb/SKILL.md` — the engine under JPA (schema, indexes, `EXPLAIN`).
- `../secure-coding/SKILL.md` — the authz/injection/secret principles behind the security rules.
- `../deployment/SKILL.md` — packaging the jar, container, CI.
- [`references/jpa.md`](references/jpa.md) · [`references/security.md`](references/security.md) · [`references/testing.md`](references/testing.md)
- `scripts/verify.sh` — best-effort static reviewer for legacy Spring idioms.
