Skip to content
Back to skills

Spring Boot 4 Conventions

ASecurity

Spring Framework 7 / Spring Boot 4 idioms and defaults. Use when writing or reviewing controllers, services, configuration, HTTP clients, async/virtual-thread code, or anything touching Spring's programming model.

  • 62 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
developmentgojavareactspringtestingcode-reviewgitapisecurity

Works with

  • cli
  • api

Security analysis

A100/100

Scanned November 7, 2026

npx -y skills add loiane/specs-driven-development-spring-angular --skill spring-boot-4-conventions --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Spring Boot 4 Conventions?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Spring Boot 4 Conventions
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/loiane-spring-boot-4-conventions/badge)](https://www.skillsdirectory.com/skills/loiane-spring-boot-4-conventions)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: spring-boot-4-conventions
description: Spring Framework 7 / Spring Boot 4 idioms and defaults. Use when writing or reviewing controllers, services, configuration, HTTP clients, async/virtual-thread code, or anything touching Spring's programming model.
when_to_use:
  - Writing new Spring components (controllers, services, configuration).
  - Reviewing PRs against Spring 6.x patterns to bring them up to Spring 7 / Boot 4.
  - Choosing between RestTemplate / WebClient / RestClient / @HttpExchange.
  - Configuring virtual threads, AOT, or structured logging.
authoritative_references:
  - https://docs.spring.io/spring-boot/reference/index.html
  - https://docs.spring.io/spring-framework/reference/index.html
---

# Spring Framework 7 / Spring Boot 4 conventions

> Java 25 + Spring Framework 7 + Spring Boot 4. Prefer the most modern idiom unless an ADR explains otherwise.

## Defaults to apply

### Package layout — by feature, not by layer

Top-level packages are **bounded contexts / features**, not technical layers.
Inside a feature, split by visibility (`api` published, private impl hidden).
For the private impl sub-packages, apply the following rule:

- **One class of a given type** (single entity, single service, etc.) — keep it directly in the feature package (no sub-package).
- **Multiple classes of the same type** — use a typed sub-package within the feature:
  - `model/` — JPA entities
  - `repository/` — Spring Data repositories
  - `service/` — service classes / implementations
  - `service/` — service implementations
- **Inside `api/`** — keep the controller (or service interface) at the `api/` level; put all request/response DTO records in an `api/dto/` sub-package.

All classes in the feature's private sub-packages (`model`, `repository`, `service`, `internal`) must be `public` (cross-package visibility is required when code spans multiple sub-packages). Cross-feature access is still forbidden and enforced by ArchUnit — `public` here means "visible within this feature", not "part of the published API".

The `api/` sub-package is the **only** published surface. Other features depend only on `<feature>.api`.

```text
com.example.checkout
├── giftcard/                    # feature/domain — one package per bounded context
│   ├── api/                     # published surface: controller, service interfaces, events
│   │   ├── GiftCardController.java
│   │   ├── GiftCardRedemptionService.java
│   │   ├── GiftCardRedeemed.java
│   │   ├── dto/                 # request/response DTOs always in api/dto/
│   │   │   ├── RedeemCommand.java
│   │   │   └── GiftCardRedemptionResponse.java
│   │   └── exception/           # domain exceptions thrown by this feature's services
│   │       └── InsufficientBalanceException.java
│   ├── model/                   # entities (multiple → typed sub-package)
│   │   ├── GiftCardEntity.java
│   │   └── GiftCardTransactionEntity.java
│   ├── repository/              # repositories (multiple → typed sub-package)
│   │   ├── GiftCardRepository.java
│   │   └── GiftCardTransactionRepository.java
│   └── service/                 # service implementations (multiple → typed sub-package)
│       └── GiftCardRedemptionServiceImpl.java
├── order/                       # another feature
│   ├── api/
│   │   └── dto/                 # DTOs always in api/dto/
│   │       └── OrderSummaryResponse.java
│   └── service/                 # single service impl — sub-package still used for consistency
│       └── OrderService.java
└── shared/                      # cross-cutting: error envelope, security config, time
    └── exception/               # GlobalExceptionHandler (@RestControllerAdvice)
```

Forbidden layouts (do **not** create these at the application root level):

```text
com.example.checkout
├── controller/        ❌ by-layer at root
├── service/           ❌ by-layer at root
├── repository/        ❌ by-layer at root
└── model/             ❌ by-layer at root
```

The forbidden layouts are **top-level** by-layer packages. Typed sub-packages
(`model/`, `repository/`, `service/`) are allowed — and encouraged when there
are multiple classes — **inside** a feature/domain package.

Why:

- Features change together; layers don't. By-feature keeps the diff for one
  change inside one package.
- Typed sub-packages within a feature improve navigation when a bounded context
  grows beyond 4–5 private classes.
- ArchUnit enforces that no other feature reaches into `model/`, `repository/`,
  or `service/` sub-packages (see `archunit-rules`).
- It maps 1:1 to the module boundaries enforced by `archunit-rules`.

Cross-feature interaction:

- Other features depend only on `<feature>.api`.
- Prefer events (`ApplicationEventPublisher`) for fire-and-forget integration.
- Direct dependencies between features are explicit and asserted by ArchUnit
  (e.g. `order` may depend on `giftcard.api`, never the reverse).

### Dependency injection

- **Constructor injection only.** No field `@Autowired`. No setter injection except where Spring forces it (e.g., a pre-existing framework callback).
- One `final` field per dependency. Write the constructor explicitly — **Lombok is forbidden** (see *No Lombok* below).

```java
@Service
public class CheckoutService {
    private final OrderRepository orders;
    private final GiftCardClient giftCards;

    public CheckoutService(OrderRepository orders, GiftCardClient giftCards) {
        this.orders = orders;
        this.giftCards = giftCards;
    }
}
```

### HTTP clients

- **Outbound HTTP:** prefer **`@HttpExchange` declarative clients** built on `RestClient`. Use `WebClient` only when the call is genuinely reactive end-to-end.
- Do **not** use `RestTemplate` in new code.
- Each external API gets its own client interface in the `infra` package.

```java
@HttpExchange(url = "/cards", accept = "application/json")
public interface GiftCardClient {
    @GetExchange("/{code}")
    GiftCard fetch(@PathVariable String code);
}
```

### Web layer

- Controllers are `@RestController`. Return DTO records, not entities.
- Use Java `record` for request/response DTOs.
- Validate input at the controller boundary:
  - Annotate the controller class with `@Validated`.
  - Annotate every `@RequestBody` parameter with `@Valid`.
  - Annotate every `@PathVariable` and `@RequestParam` with the appropriate Bean Validation constraint (`@Positive`, `@NotBlank`, `@Max`, etc.).
  - Always add tests that exercise each constraint; a constraint with no test is untested behaviour.
- **Never use `Pageable` as a controller parameter.** `Pageable` is a Spring Data internal type; exposing it on the API surface leaks persistence concerns, makes parameters implicit to callers, and prevents OpenAPI tooling from generating correct query-param docs. Instead:
  - Declare explicit `@RequestParam` fields (`page`, `size`, sort params).
  - Apply Bean Validation constraints directly (`@PositiveOrZero`, `@Positive`, `@Max(100)`).
  - Construct `PageRequest.of(page, size)` inside the method and pass it to the service.

  ```java
  @GetMapping
  FooPageResponse list(
      @RequestParam(defaultValue = "0") @PositiveOrZero int page,
      @RequestParam(defaultValue = "25") @Positive @Max(100) int size) {
    return fooService.getPage(PageRequest.of(page, size));
  }
  ```

- **Avoid `ResponseEntity<T>` as a return type** unless varying the HTTP status code at runtime is unavoidable. For the common cases:
  - **Add a response header** (e.g. `ETag`, `Location`) → inject `HttpServletResponse` and call `response.setHeader(...)`, then return the DTO directly.
  - **201 Created with Location** → use `@ResponseStatus(HttpStatus.CREATED)` on the method + set the `Location` header via `HttpServletResponse`.
  - `ResponseEntity` is acceptable only in handlers that need to conditionally vary the status (e.g. `304 Not Modified` based on `If-None-Match`).
- Map exceptions via `@RestControllerAdvice` to a single, documented error envelope.

```java
// in <feature>/api/dto/
public record ApplyGiftCardRequest(@NotBlank String code, @Min(0) int orderTotalCents) {}
public record ApplyGiftCardResponse(int redeemedCents, int newOrderTotalCents) {}
```

### Persistence

- Spring Data JPA is the default. Use `@Query` for non-trivial reads; never rely on derived queries longer than three predicates.
- Always paginate list endpoints (`Pageable`). Reject unbounded queries.
- Use `@ServiceConnection` with Testcontainers in tests instead of `application-test.yml` overrides.
- **Use `Instant` for audit timestamps** (`created_at`, `updated_at`), not `LocalDateTime`. `Instant` is an absolute UTC point in time; `LocalDateTime` is ambiguous across DST transitions and timezone changes. Hibernate 6 maps `Instant` to `DATETIME`/`TIMESTAMP` columns using UTC normalization — no schema change required.

### Virtual threads

- Spring Boot 4 enables virtual threads for the web server by default. Keep it on. Do not introduce custom `Executor`s that pin to platform threads without measuring.

### Configuration

- One `@ConfigurationProperties` record per bounded module. Validate with `@Validated`.
- No `@Value` for grouped settings. Use `@Value` only for ad-hoc single primitives.

```java
@ConfigurationProperties("app.giftcard")
@Validated
public record GiftCardProperties(@NotBlank String baseUrl, @Min(1) int timeoutMs) {}
```

### Observability

- Structured logging via `Logger` + key/value pairs (Boot 4 native `StructuredLoggingFormatter`).
- Metrics via `MeterRegistry`; one counter per business outcome (`gift_card.redeemed`, `gift_card.rejected`).

### AOT-friendly

- No reflection on application code.
- Beans are `@Component`, `@Service`, etc. at compile time. No runtime registration.
- Anything dynamic is registered via a `RuntimeHintsRegistrar` and noted in the design doc.

## Anti-patterns to flag in review

- Field injection (`@Autowired` on a field).
- Lombok in any form (`@Data`, `@Getter`, `@Setter`, `@Builder`, `@RequiredArgsConstructor`, `@Slf4j`, `@SneakyThrows`, etc.) — see *No Lombok* below.
- Top-level packages named `controller`, `service`, `repository`, `model`, `dto`, `util` (by-layer layout at the application root) — use feature/domain packages instead (see *Package layout*). Note: `model/`, `repository/`, and `service/` sub-packages *within* a feature package are allowed when there are multiple classes of that type.
- `RestTemplate` in new code.
- Returning entities from controllers.
- Untyped `Map<String, Object>` request/response bodies.
- Catching `Exception` then rethrowing `RuntimeException` with no message.
- `@SpringBootTest` for code that a slice test (`@WebMvcTest`, `@DataJpaTest`) can cover.
- `Pageable` as a controller method parameter — use explicit `@RequestParam` fields instead (see *Web layer*).
- `ResponseEntity<T>` as a return type when a simpler alternative exists (see *Web layer*).
- Controller input constraints (`@Positive`, `@Max`, `@Valid`, etc.) without corresponding tests — every constraint must have at least one test that fires it. (`FooService` interface in `api` + `FooServiceImpl` class in `internal`) when there is only one implementation and no other feature depends on the interface. The `api` package is the published cross-feature surface; an interface that nothing outside the feature uses does not belong there and defeats the ArchUnit `internal` guard. Use a concrete `@Service` class named `FooService` directly in `internal`. Reserve an `api`-package interface only when another feature's code must depend on it (e.g. a port for inversion of control across bounded contexts).
- `status().isUnprocessableEntity()` in `MockMvc` tests — deprecated and removed in Spring MVC Test 7.0; use `status().is(422)` instead.
- `new MappingJackson2HttpMessageConverter()` passed to `standaloneSetup(...).setMessageConverters(...)` — `MappingJackson2HttpMessageConverter` is removed in Spring 7; drop the `.setMessageConverters()` call entirely (`standaloneSetup` auto-registers Jackson).
- **Fully-qualified type names (FQNs) inline in method signatures or bodies** — never write `jakarta.servlet.http.HttpServletResponse response` or `throws java.io.IOException` directly in code; always add the proper `import` statement at the top of the file and use the simple class name. FQNs in code bypass IDE navigation, break Spotless/google-java-format style checks, and make code harder to read.

## Java 25 features to use freely

- Records and sealed types for DTOs and domain.
- Pattern matching for `switch`.
- Virtual threads (default).
- Sequenced collections.

## No Lombok

Lombok is **forbidden** in new code, including new modules added to brownfield repos.

Why:

- Java 25 records, accessors, and concise constructors cover the legitimate use cases.
- Lombok is a compile-time bytecode rewriter that interferes with AOT, code coverage, mutation testing, and static analysis.
- It hides constructor signatures, which makes constructor-injection review harder.

Replacements:

| Lombok | Use instead |
|---|---|
| `@Data`, `@Value`, `@Builder` on a DTO | Java `record` (with a static factory or compact constructor for validation) |
| `@Getter` / `@Setter` | Plain accessors; for immutable types, the record's auto-generated accessors |
| `@RequiredArgsConstructor` / `@AllArgsConstructor` | Write the constructor explicitly (one-time cost; clarifies dependency graph) |
| `@Slf4j` | `private static final Logger log = LoggerFactory.getLogger(MyClass.class);` |
| `@SneakyThrows` | Declare the checked exception or wrap it explicitly with a meaningful message |
| `@EqualsAndHashCode`, `@ToString` | Records get these for free; for non-records, write them or use `Objects.equals` / `Objects.hash` |

Enforcement:

- The `pom.xml` must not declare `org.projectlombok:lombok` as a dependency in new modules.
- Add an ArchUnit rule (see `archunit-rules` skill): `noClasses().should().dependOnClassesThat().resideInAPackage("lombok..")`.
- `spring-code-reviewer` flags any Lombok import as a **must-fix**.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.