| 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.
Default: flat feature package. All classes live directly in com.example.<feature> while the feature is small. Add typed sub-packages only when the feature package becomes crowded (rule of thumb: more than ~6–8 classes of mixed types, or when navigation becomes hard).
When the feature grows, extract into these typed sub-packages (add only the ones needed — do not pre-create empty ones):
| Sub-package | Contents |
|---|
controller/ | @RestController, @RestControllerAdvice |
service/ | @Service classes |
repository/ | Spring Data repositories |
model/ | JPA entities |
dto/ | Request/response records |
exception/ | Domain exceptions |
enum/ | Domain enumerations |
All classes in typed sub-packages must be public (cross-package visibility is required within the feature). Cross-feature access is still forbidden and enforced by ArchUnit — public here means "visible within this feature", not "part of the application's public API".
# Small feature — flat (preferred while the package stays manageable)
com.example.customer
├── Customer.java # JPA entity
├── CustomerRepository.java # Spring Data repo
├── CustomerRequest.java # request record
├── CustomerResponse.java # response record
├── CustomerService.java # @Service
├── CustomerController.java # @RestController
└── DuplicateEmailException.java
# Grown feature — typed sub-packages extracted as needed
com.example.order
├── controller/
│ ├── OrderController.java
│ └── OrderExceptionHandler.java
├── service/
│ ├── OrderService.java
│ └── OrderFulfillmentService.java
├── repository/
│ ├── OrderRepository.java
│ └── OrderItemRepository.java
├── model/
│ ├── Order.java
│ └── OrderItem.java
├── dto/
│ ├── CreateOrderRequest.java
│ └── OrderResponse.java
├── exception/
│ ├── OrderNotFoundException.java
│ └── InsufficientInventoryException.java
└── enum/
└── OrderStatus.java
Forbidden layouts (do not create these at the application root level):
com.example
├── controller/ ❌ by-layer at root
├── service/ ❌ by-layer at root
├── repository/ ❌ by-layer at root
└── model/ ❌ by-layer at root
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 as a bounded context grows beyond ~6–8 classes.
- ArchUnit enforces that no other feature reaches into another feature's sub-packages (see
archunit-rules).
Cross-feature interaction:
- Avoid importing from another feature's package whenever possible. If a type is needed by two features, move it to a
shared/ package (e.g. com.example.shared).
- Prefer events (
ApplicationEventPublisher) for fire-and-forget integration over direct calls between features.
- When a direct dependency between features is unavoidable, it must be unidirectional and explicitly asserted by ArchUnit (e.g.
order may depend on customer, 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).
@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.
@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.
@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.
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
Executors 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.
@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 + FooServiceImpl class when there is only one implementation — use a single concrete @Service class named FooService directly in the feature package. No interface, no Impl suffix. Reserve a service interface only when another feature's code must depend on it (e.g. a cross-bounded-context port). Unnecessary interfaces add files, inflate tasks, require @InjectMocks to reference FooServiceImpl, and convey false intent.
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.