Skills for creating, testing, reviewing, migrating and guarding Spring Boot 4 projects with Java 25: project scaffolding with architecture patterns, Boot 4 test templates, code review, Boot 3 to 4 migration, and a team harness of build gates and agent hooks.
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
Spring Boot 4 (built on Spring Framework 7; Java 17 minimum) includes six major features that eliminate the need for external libraries and improve developer experience. Recommended toolchain: Java 25 (LTS). See java-version-policy.md for newer releases.
Recommended new HTTP test client. TestRestTemplate is not deprecated, but @SpringBootTest no longer auto-provides it in Boot 4 — opt in via @AutoConfigureTestRestTemplate to keep existing tests, or migrate to RestTestClient for new ones.
Benefits:
.apiVersion("2.0")Template: resttestclient-test.java
Basic Usage:
@SpringBootTest(webEnvironment = RANDOM_PORT)
class ProductControllerTest {
@Autowired
private WebApplicationContext context;
private RestTestClient client;
@BeforeEach
void setup() {
client = RestTestClient.bindToApplicationContext(context)
.apiVersionInserter(ApiVersionInserter.useHeader("X-API-Version"))
.build();
}
@Test
void shouldGetProduct() {
ProductVM product = client.get()
.uri("/api/products/{id}", "PRD-001")
.exchange()
.expectStatus().isOk()
.expectBody(ProductVM.class)
.returnResult()
.getResponseBody();
assertThat(product.id()).isEqualTo("PRD-001");
}
}Key Methods:
.get(), .post(), .patch(), .delete() - HTTP methods.uri() - Set request URI with path variables.apiVersion("2.0") - Set API version header.exchange() - Execute request.expectStatus() - Assert status code.expectBody() - Assert response bodyWhat's Native in Spring Framework 7:
@Retryable — automatic retry with backoff (org.springframework.resilience.annotation)@ConcurrencyLimit — concurrent-invocation throttling. From the Framework reference: "specifies a concurrency limit for an individual method ... meant to protect the target resource from being accessed from too many threads at the same time, similar to the effect of a pool size limit for a thread pool or a connection pool that blocks access if its limit is reached." This is not time-window rate limiting and not the Resilience4j bulkhead.What Still Requires External Libraries:
spring-cloud-starter-circuitbreaker-resilience4j)@ConcurrencyLimit only caps in-flight concurrency.Benefits:
Decision note:
org.springframework.resilience.annotation.* for new Boot 4 projects and remove spring-retry if not needed.Template: resilience-service.java
import org.springframework.resilience.annotation.Retryable;
@Service
public class ProductService {
// 1 initial attempt + maxRetries — up to 5 total invocations here
@Retryable(
includes = {RuntimeException.class},
maxRetries = 4,
delay = 2000L // milliseconds
)
public Optional<Product> fetchFromExternalApi(String id) {
return externalClient.getById(id);
}
// Optional: Advanced configuration with exponential backoff (up to 4 total invocations)
@Retryable(
includes = {IOException.class},
maxRetries = 3,
delay = 1000,
multiplier = 2
)
public String callRemoteService() {
// transient failures handled automatically
}
}Configuration:
@EnableResilientMethods on a @Configuration classorg.springframework.resilience.annotation.*Parameters:
includes - Exception types that trigger retryexcludes - Exception types that should never retrymaxRetries - Number of retries after the initial attempt (default: 3 — total invocations = 1 + maxRetries)delay / delayString - Delay between retriesmultiplier - Exponential backoff multipliermaxDelay - Maximum delay ceilingUse cases:
import org.springframework.resilience.annotation.ConcurrencyLimit;
@Service
public class ReportService {
@ConcurrencyLimit(2) // At most 2 invocations in flight at any time
public void processExpensiveOperation(String id) {
performExpensiveWork(id);
}
}Semantics: when the third caller arrives while 2 invocations are already running, it blocks until a slot frees up — same shape as a fixed-size connection pool. It is not a time-window rate limit and does not implement Resilience4j-style bulkhead queueing.
Configuration:
@EnableResilientMethods on a @Configuration classorg.springframework.resilience.annotation.*Good fits:
NOT this annotation's job:
For scenarios needing more control than @Retryable:
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryTemplate;
@Service
public class DriverAssignmentService {
private final RetryTemplate retryTemplate;
public DriverAssignmentService() {
// 1 initial attempt + maxRetries — up to 11 total invocations
RetryPolicy retryPolicy = RetryPolicy.builder()
.maxRetries(10)
.delay(Duration.ofMillis(2000))
.multiplier(1.5)
.maxDelay(Duration.ofMillis(10000))
.includes(NoDriversAvailableException.class)
.build();
this.retryTemplate = new RetryTemplate(retryPolicy);
}
public Driver assignDriver(Order order) {
return retryTemplate.execute(() -> {
// logic that may throw NoDriversAvailableException
return findAvailableDriver(order);
});
}
}Observability with RetryListener:
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.Retryable;
@Component
public class MetricsRetryListener implements RetryListener {
@Override
public void beforeRetry(RetryPolicy policy, Retryable<?> retryable) {
// track attempt count, log retry start
}
@Override
public void onRetrySuccess(RetryPolicy policy, Retryable<?> retryable, Object result) {
// track successful recovery
}
@Override
public void onRetryFailure(RetryPolicy policy, Retryable<?> retryable, Throwable t) {
// track exhausted retries
}
}Attach to template: retryTemplate.setRetryListener(listener)
Source: danvega/quick-bytes (Spring Boot 4.0.0-RC2)
NOT native in Spring Boot 4. For circuit breaker patterns, use Resilience4j:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
<!-- Use the Boot 4-specific starter. The dedicated resilience4j-spring-boot4 module is currently 2.4.0 on Maven Central — confirm the latest at https://central.sonatype.com/artifact/io.github.resilience4j/resilience4j-spring-boot4 before pinning. -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot4</artifactId>
</dependency>@Service
public class ProductService {
@CircuitBreaker(name = "productService", fallbackMethod = "fallbackFetch")
public List<Product> fetchFromUnreliableService() {
return externalClient.getAll();
}
private List<Product> fallbackFetch(Exception e) {
return Collections.emptyList();
}
}Configuration (application.yml):
resilience4j:
circuitbreaker:
instances:
productService:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10⚠️ Note: Verify the current Resilience4j release before upgrading — see the Resilience4j releases page. The dedicated resilience4j-spring-boot4 module is the right starter for Boot 4; the older resilience4j-spring-boot3 is for Boot 3 stacks.
What's new: @ImportHttpServices auto-configuration (Spring Framework 7.0) replaces manual HttpServiceProxyFactory bean wiring. @HttpExchange itself exists since Framework 6.0.
Benefits:
Template: http-service-client.java
Before Spring Boot 4:
@Bean
ProductClient productClient(RestClient.Builder builder) {
RestClient restClient = builder.baseUrl("http://api.example.com").build();
var factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
return factory.createClient(ProductClient.class);
}With Spring Boot 4:
// 1. Define interface
@HttpExchange
public interface ProductClient {
@GetExchange(url = "/api/products/{id}")
Optional<ProductDTO> findById(@PathVariable Long id);
@PostExchange(url = "/api/products")
ProductDTO create(CreateProductRequest request);
}
// 2. Auto-configure — group organizes services sharing the same HTTP client config
@Configuration
@ImportHttpServices(group = "product", types = ProductClient.class)
public class ClientConfig {
// That's it! RestClient is the default client type.
}Features:
@HttpExchange - Mark interface as HTTP client@GetExchange, @PostExchange, @PutExchange, @DeleteExchange - HTTP methods@GetExchange(url = "...", version = "2.0")@PathVariable@RequestParamConfiguration (application.yml):
spring:
http:
clients:
connect-timeout: 5s
serviceclient:
product: # Group name (matches @ImportHttpServices group)
base-url: http://localhost:8080
read-timeout: 10sReplaces: Custom versioning implementations, external libraries
Benefits:
Template: api-versioning-config.java
Configuration Option 1 - Properties (Recommended):
spring:
mvc:
apiversion:
default: "1.0"
supported: "1.0,2.0"
use:
header: X-API-Version
# Alternative strategies (use only one resolver):
# query-parameter: version
# path-segment: 1
# media-type-parameter[application/json]: versionConfiguration Option 2 - Java (WebMvcConfigurer.configureApiVersioning):
import org.springframework.web.accept.SemanticApiVersionParser;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class ApiVersioningConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.useRequestHeader("X-API-Version") // resolver
// .useQueryParam("version")
// .useMediaTypeParameter(MediaType.APPLICATION_JSON, "version")
// .usePathSegment(1)
.addSupportedVersions("1.0", "2.0")
.setDefaultVersion("1.0")
.setVersionParser(new SemanticApiVersionParser()); // optional — default
}
}Notes: ApiVersionConfigurer lives in org.springframework.web.servlet.config.annotation. The default parser is SemanticApiVersionParser from org.springframework.web.accept. There are no ApiVersionResolver.fromHeader(...) / ApiVersionParser.semantic() static factories.
Versioning Strategies:
X-API-Version: 2.0/api/products?version=2.0Accept: application/json;ver=2.0/v2/api/products (less common with native support)Controller Implementation:
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping(value = "/search", version = "1.0")
public List<ProductVM> searchV1(@RequestParam("q") String query) {
return service.searchByTitle(query);
}
@GetMapping(value = "/search", version = "2.0")
public List<ProductEnrichedVM> searchV2(@RequestParam("q") String query) {
return service.searchWithDetails(query);
}
}Testing with RestTestClient:
client.get()
.uri("/api/products/search?q=test")
.apiVersion("2.0") // Sets X-API-Version header
.exchange()
.expectStatus().isOk();HTTP Service Client with Versioning:
@HttpExchange
public interface ProductClient {
@GetExchange(url = "/api/products/search", version = "1.0")
List<ProductVM> searchV1(@RequestParam("q") String query);
@GetExchange(url = "/api/products/search", version = "2.0")
List<ProductEnrichedVM> searchV2(@RequestParam("q") String query);
}Benefits:
Configuration (pom.xml):
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>When to use:
Limitations:
Benefits:
Template: package-info-jspecify.java
Package-level Configuration:
@NullMarked
package com.example.products;
import org.jspecify.annotations.NullMarked;Usage:
// All types are non-null by default
public class ProductEntity {
private Long id; // Non-null (required)
private String name; // Non-null (required)
@Nullable private String description; // Nullable (optional)
public @Nullable String getDescription() {
return description;
}
}
// Repository with Optional
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
// Good: Use Optional for potentially absent values
Optional<ProductEntity> findByName(String name);
// Good: List is never null
List<ProductEntity> findByStatus(String status);
}
// Controller with validation
@RestController
public class ProductController {
@GetMapping("/{id}")
public ProductVM getById(@PathVariable Long id) { // Non-null by default with @NullMarked
return service.findById(id);
}
@GetMapping("/search")
public List<ProductVM> search(
@RequestParam @Nullable String query, // Optional
@RequestParam(defaultValue = "0") int page
) {
return service.search(query, page);
}
}IDE Configuration:
Best Practices:
Boot 4 uses modular starters. Add only what you need:
| Starter | Provides |
|---|---|
spring-boot-starter-webmvc | Spring MVC (replaces spring-boot-starter-web) |
spring-boot-starter-restclient | RestClient / RestTemplate |
spring-boot-starter-webclient | WebClient (reactive) |
spring-boot-starter-aspectj | AspectJ @Aspect support, Micrometer @Timed/@Observed, Resilience4j annotations. Not needed for native @Retryable/@ConcurrencyLimit |
Verify versions:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<properties>
<!-- Boot 4 baseline is 17. Recommended: 25 (LTS).
For newer releases see java-version-policy.md. -->
<java.version>25</java.version>
</properties>This file covers new projects. For upgrades, use the springboot-migration skill and ../../springboot-migration/references/spring-boot-4-migration.md.