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.
Install
mkdir -p .claude/skills/spring-boot-4-conventions && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/13398" && unzip -o skill.zip -d .claude/skills/spring-boot-4-conventions && rm skill.zipInstalls to .claude/skills/spring-boot-4-conventions
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
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.About this skill
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 entitiesrepository/— Spring Data repositoriesservice/— service classes / implementationsservice/— service implementations
- Inside
api/— keep the controller (or service interface) at theapi/level; put all request/response DTO records in anapi/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.
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):
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/, orservice/sub-packages (seearchunit-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.
ordermay depend ongiftcard.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
finalfield per dependency. Write the constructor explicitly — Lombok is forbidden (see No Lombok below).
@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
@HttpExchangedeclarative clients built onRestClient. UseWebClientonly when the call is genuinely reactive end-to-end. - Do not use
RestTemplatein new code. - Each external API gets its own client interface in the
infrapackage.
@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
recordfor request/response DTOs. - Validate input at the controller boundary:
- Annotate the controller class with
@Validated. - Annotate every
@RequestBodyparameter with@Valid. - Annotate every
@PathVariableand@RequestParamwith the appropriate Bean Validation constraint (@Positive,@NotBlank,@Max, etc.). - Always add tests that exercise each constraint; a constraint with no test is untested behaviour.
- Annotate the controller class with
- Never use
Pageableas a controller parameter.Pageableis 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
@RequestParamfields (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.
@GetMapping FooPageResponse list( @RequestParam(defaultValue = "0") @PositiveOrZero int page, @RequestParam(defaultValue = "25") @Positive @Max(100) int size) { return fooService.getPage(PageRequest.of(page, size)); } - Declare explicit
- 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) → injectHttpServletResponseand callresponse.setHeader(...), then return the DTO directly. - 201 Created with Location → use
@ResponseStatus(HttpStatus.CREATED)on the method + set theLocationheader viaHttpServletResponse. ResponseEntityis acceptable only in handlers that need to conditionally vary the status (e.g.304 Not Modifiedbased onIf-None-Match).
- Add a response header (e.g.
- Map exceptions via
@RestControllerAdviceto a single, documented error envelope.
// 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
@Queryfor non-trivial reads; never rely on derived queries longer than three predicates. - Always paginate list endpoints (
Pageable). Reject unbounded queries. - Use
@ServiceConnectionwith Testcontainers in tests instead ofapplication-test.ymloverrides. - Use
Instantfor audit timestamps (created_at,updated_at), notLocalDateTime.Instantis an absolute UTC point in time;LocalDateTimeis ambiguous across DST transitions and timezone changes. Hibernate 6 mapsInstanttoDATETIME/TIMESTAMPcolumns 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
Executors that pin to platform threads without measuring.
Configuration
- One
@ConfigurationPropertiesrecord per bounded module. Validate with@Validated. - No
@Valuefor grouped settings. Use@Valueonly for ad-hoc single primitives.
@ConfigurationProperties("app.giftcard")
@Validated
public record GiftCardProperties(@NotBlank String baseUrl, @Min(1) int timeoutMs) {}
Observability
- Structured logging via
Logger+ key/value pairs (Boot 4 nativeStructuredLoggingFormatter). - 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
RuntimeHintsRegistrarand noted in the design doc.
Anti-patterns to flag in review
- Field injection (
@Autowiredon 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/, andservice/sub-packages within a feature package are allowed when there are multiple classes of that type. RestTemplatein new code.- Returning entities from controllers.
- Untyped
Map<String, Object>request/response bodies. - Catching
Exceptionthen rethrowingRuntimeExceptionwith no message. @SpringBootTestfor code that a slice test (@WebMvcTest,@DataJpaTest) can cover.Pageableas a controller method parameter — use
Content truncated.