๐ Spring Boot 4.0.0 Preview: A Relaxed Deep Dive into What’s New and Exciting!#
Hey there, Spring enthusiasts! ๐ Ready to explore the shiny new features in Spring Boot 4.0.0? Grab your favorite beverage โ because we’re about to embark on a fun journey through the latest preview release built on Spring Framework 7.0.0. Trust me, there’s a lot to get excited about!
๐ฏ Quick Overview#
Spring Boot 4.0.0 preview is here, and it’s bringing some serious game-changers to the table! Built on Spring Framework 7.0.0, this release focuses on making our lives easier with better null safety, smoother API versioning, and modern HTTP client options [1].
graph TD
A[Spring Boot 4.0.0] --> B[Spring Framework 7.0.0]
B --> C[JSpecify Annotations]
B --> D[API Versioning]
B --> E[Enhanced RestClient]
B --> F[Java 17+ Features]
C --> G[Better Null Safety]
D --> H[Multiple API Versions]
E --> I[Modern HTTP Calls]
F --> J[Virtual Threads Support]
โ ๏ธ Friendly Reminder: This is a preview release, so don’t rush to production just yet! Test it out in your sandbox first [3].
๐จ API Versioning Made Easy#
Remember the headaches of maintaining multiple API versions? Well, Spring Framework 7.0.0 just made it a breeze! You can now elegantly manage different versions of your endpoints using the version
parameter in @RequestMapping
[1].
Basic Example#
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/api")
public class UserController {
// Version 1.0 - Basic user info
@GetMapping(value = "/users/{id}", version = "1.0")
public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
UserV1 user = new UserV1(id, "John Doe", "[email protected]");
return ResponseEntity.ok(user);
}
// Version 2.0 - Enhanced user info with metadata
@GetMapping(value = "/users/{id}", version = "2.0")
public ResponseEntity<UserV2> getUserV2(@PathVariable Long id) {
UserV2 user = new UserV2(
id,
"John Doe",
"[email protected]",
LocalDateTime.now(),
true,
"Premium"
);
return ResponseEntity.ok(user);
}
// Version 3.0 - Full user profile with preferences
@GetMapping(value = "/users/{id}", version = "3.0")
public ResponseEntity<UserV3> getUserV3(@PathVariable Long id) {
UserPreferences prefs = new UserPreferences("dark", "en-US", true);
UserV3 user = new UserV3(
id,
"John Doe",
"[email protected]",
LocalDateTime.now(),
true,
"Premium",
prefs,
List.of("ROLE_USER", "ROLE_PREMIUM")
);
return ResponseEntity.ok(user);
}
}
// DTOs for different versions
record UserV1(Long id, String name, String email) {}
record UserV2(
Long id,
String name,
String email,
LocalDateTime createdAt,
boolean active,
String tier
) {}
record UserV3(
Long id,
String name,
String email,
LocalDateTime createdAt,
boolean active,
String tier,
UserPreferences preferences,
List<String> roles
) {}
record UserPreferences(String theme, String language, boolean notifications) {}
Advanced Versioning with Custom Headers#
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
// Using custom version header
@GetMapping(headers = "X-API-Version=1.0")
public List<ProductV1> getProductsV1() {
return productService.findAll().stream()
.map(p -> new ProductV1(p.getId(), p.getName(), p.getPrice()))
.toList();
}
@GetMapping(headers = "X-API-Version=2.0")
public List<ProductV2> getProductsV2() {
return productService.findAll().stream()
.map(p -> new ProductV2(
p.getId(),
p.getName(),
p.getPrice(),
p.getDescription(),
p.getStock(),
p.getCategory()
))
.toList();
}
}
๐ก๏ธ Null Safety Revolution with JSpecify#
Say goodbye to those pesky NullPointerExceptions! Spring Framework 7.0.0 has adopted JSpecify annotations (@Nullable
and @NonNull
), replacing the deprecated JSR 305 annotations [2]. This is huge for code reliability!
Before and After Comparison#
// โ Old way (Spring Boot 3.x)
import javax.annotation.Nullable;
import org.springframework.lang.NonNull;
@Service
public class OldUserService {
public User findUser(@Nullable String username) {
// Potential NPE if not careful
return userRepository.findByUsername(username);
}
}
// โ
New way (Spring Boot 4.0.0)
import org.jspecify.annotations.Nullable;
import org.jspecify.annotations.NonNull;
@Service
public class ModernUserService {
private final UserRepository userRepository;
public ModernUserService(@NonNull UserRepository userRepository) {
this.userRepository = Objects.requireNonNull(userRepository, "UserRepository cannot be null");
}
@NonNull
public Optional<User> findUser(@Nullable String username) {
if (username == null || username.isBlank()) {
return Optional.empty();
}
return userRepository.findByUsername(username);
}
@NonNull
public User createUser(@NonNull UserCreateRequest request) {
Objects.requireNonNull(request, "User creation request cannot be null");
Objects.requireNonNull(request.username(), "Username cannot be null");
Objects.requireNonNull(request.email(), "Email cannot be null");
return userRepository.save(new User(
request.username(),
request.email(),
request.fullName() // This can be null
));
}
}
record UserCreateRequest(
@NonNull String username,
@NonNull String email,
@Nullable String fullName
) {}
Comprehensive Null Safety Example#
@RestController
@RequestMapping("/api/v2/users")
public class NullSafeUserController {
private final UserService userService;
private final ValidationService validationService;
public NullSafeUserController(
@NonNull UserService userService,
@NonNull ValidationService validationService
) {
this.userService = Objects.requireNonNull(userService);
this.validationService = Objects.requireNonNull(validationService);
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody @NonNull UserRequest request) {
// Validate request
ValidationResult validation = validationService.validate(request);
if (!validation.isValid()) {
return ResponseEntity.badRequest()
.body(new UserResponse(null, validation.getErrors()));
}
try {
User user = userService.createUser(request);
return ResponseEntity.ok(new UserResponse(user, null));
} catch (Exception e) {
return ResponseEntity.internalServerError()
.body(new UserResponse(null, List.of(e.getMessage())));
}
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable @NonNull Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
record UserResponse(
@Nullable User user,
@Nullable List<String> errors
) {}
๐ Modern HTTP Clients#
Spring Boot 4.0.0 brings enhanced support for modern HTTP clients. While RestTemplate
is still around (for now), the focus has shifted to RestClient
and WebClient
[4].
RestClient - The New Synchronous Hero#
@Service
public class ExternalApiService {
private final RestClient restClient;
public ExternalApiService(RestClient.Builder builder) {
this.restClient = builder
.baseUrl("https://api.example.com")
.defaultHeader("User-Agent", "Spring-Boot-4.0-App")
.defaultHeader("Accept", "application/json")
.requestInterceptor((request, body, execution) -> {
System.out.println("Making request to: " + request.getURI());
return execution.execute(request, body);
})
.build();
}
// Simple GET request
public List<Product> getProducts() {
return restClient.get()
.uri("/products")
.retrieve()
.body(new ParameterizedTypeReference<List<Product>>() {});
}
// POST with request body
public Order createOrder(OrderRequest orderRequest) {
return restClient.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(orderRequest)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
throw new ClientException("Client error: " + response.getStatusCode());
})
.body(Order.class);
}
// Advanced example with error handling
public Optional<User> getUserById(Long id) {
try {
User user = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.onStatus(status -> status.value() == 404, (request, response) -> {
// Don't throw exception for 404
})
.body(User.class);
return Optional.ofNullable(user);
} catch (Exception e) {
log.error("Error fetching user with id: {}", id, e);
return Optional.empty();
}
}
}
WebClient - Reactive Powerhouse#
@Service
public class ReactiveApiService {
private final WebClient webClient;
public ReactiveApiService(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("https://api.example.com")
.filter(ExchangeFilterFunction.ofRequestProcessor(request -> {
log.info("Request: {} {}", request.method(), request.url());
return Mono.just(request);
}))
.build();
}
// Non-blocking GET
public Mono<List<Product>> getProductsReactive() {
return webClient.get()
.uri("/products")
.retrieve()
.bodyToFlux(Product.class)
.collectList();
}
// Parallel requests
public Mono<CombinedData> getCombinedData(Long userId) {
Mono<User> userMono = webClient.get()
.uri("/users/{id}", userId)
.retrieve()
.bodyToMono(User.class);
Mono<List<Order>> ordersMono = webClient.get()
.uri("/users/{id}/orders", userId)
.retrieve()
.bodyToFlux(Order.class)
.collectList();
Mono<UserPreferences> preferencesMono = webClient.get()
.uri("/users/{id}/preferences", userId)
.retrieve()
.bodyToMono(UserPreferences.class);
return Mono.zip(userMono, ordersMono, preferencesMono)
.map(tuple -> new CombinedData(tuple.getT1(), tuple.getT2(), tuple.getT3()));
}
// Streaming response
public Flux<StockPrice> streamStockPrices(String symbol) {
return webClient.get()
.uri("/stocks/{symbol}/stream", symbol)
.retrieve()
.bodyToFlux(StockPrice.class)
.retry(3)
.onErrorResume(error -> {
log.error("Error streaming stock prices", error);
return Flux.empty();
});
}
}
record CombinedData(User user, List<Order> orders, UserPreferences preferences) {}
record StockPrice(String symbol, BigDecimal price, LocalDateTime timestamp) {}
โก Performance Improvements#
Spring Boot 4.0.0 isn’t just about new features - it’s also about making things faster! With support for Java 17+ features and optimizations in Spring Framework 7.0.0, you’ll notice improvements across the board [3].
graph LR
A[Performance Improvements] --> B[Virtual Threads]
A --> C[Optimized Bean Creation]
A --> D[Faster Startup]
A --> E[Reduced Memory Footprint]
B --> F[Better Concurrency]
C --> G[Lazy Initialization]
D --> H[AOT Processing]
E --> I[Native Image Support]
Virtual Threads Example#
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@Service
public class VirtualThreadService {
@Async("virtualThreadExecutor")
public CompletableFuture<String> processAsync(String data) {
// This runs on a virtual thread!
log.info("Processing on virtual thread: {}", Thread.currentThread());
// Simulate some work
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return CompletableFuture.completedFuture("Processed: " + data);
}
public void processManyItems(List<String> items) {
List<CompletableFuture<String>> futures = items.stream()
.map(this::processAsync)
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenRun(() -> log.info("All items processed!"))
.join();
}
}
๐ Easy HTTP Proxy Creation with @ImportHttpServices#
Spring Framework 7.0 introduces the @ImportHttpServices
annotation, which dramatically simplifies creating HTTP client proxies for microservice communication [1]. This feature leverages the HttpServiceProxyFactory
to automatically generate implementation classes for your HTTP service interfaces [2].
Core Concepts#
The @ImportHttpServices
annotation allows you to declaratively configure multiple HTTP service clients in a single place, eliminating boilerplate code and making your microservice integrations more maintainable [1].
Basic Example: Defining HTTP Service Interfaces#
// Define HTTP service interface with exchange methods
public interface UserServiceClient {
@GetExchange("/users/{id}")
User findById(@PathVariable Long id);
@GetExchange("/users")
List<User> findAll(@RequestParam(required = false) String status);
@PostExchange("/users")
User create(@RequestBody CreateUserRequest request);
@PutExchange("/users/{id}")
User update(@PathVariable Long id, @RequestBody UpdateUserRequest request);
@DeleteExchange("/users/{id}")
void delete(@PathVariable Long id);
@GetExchange("/users/search")
Page<User> search(@RequestParam String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size);
}
// Another service interface
public interface ProductServiceClient {
@GetExchange("/products/{sku}")
Product findBySku(@PathVariable String sku);
@PostExchange("/products/batch")
List<Product> createBatch(@RequestBody List<CreateProductRequest> requests);
@PatchExchange("/products/{id}/inventory")
void updateInventory(@PathVariable Long id,
@RequestBody InventoryUpdate update);
}
Configuration with @ImportHttpServices#
@Configuration
@ImportHttpServices({
@HttpService(
value = UserServiceClient.class,
url = "${services.user.base-url:http://user-service:8080}"
),
@HttpService(
value = ProductServiceClient.class,
url = "${services.product.base-url:http://product-service:8081}"
),
@HttpService(
value = OrderServiceClient.class,
url = "${services.order.base-url:http://order-service:8082}"
)
})
public class HttpServicesConfiguration {
@Bean
public RestClient.Builder restClientBuilder() {
return RestClient.builder()
.defaultHeader("X-Application-Name", "api-gateway")
.defaultHeader("X-Trace-Id", () -> MDC.get("traceId"))
.requestInterceptor(new LoggingInterceptor())
.messageConverters(converters -> {
converters.add(0, new MappingJackson2HttpMessageConverter(customObjectMapper()));
});
}
@Bean
public ObjectMapper customObjectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
}
Advanced Configuration Example#
@Configuration
@EnableConfigurationProperties(ServiceDiscoveryProperties.class)
public class AdvancedHttpServicesConfiguration {
private final ServiceDiscoveryProperties discoveryProperties;
public AdvancedHttpServicesConfiguration(ServiceDiscoveryProperties discoveryProperties) {
this.discoveryProperties = discoveryProperties;
}
@Bean
public HttpServiceProxyFactory httpServiceProxyFactory(
RestClient.Builder builder,
LoadBalancer loadBalancer) {
RestClient restClient = builder
.requestFactory(clientHttpRequestFactory())
.defaultStatusHandler(HttpStatusCode::isError, new GlobalErrorHandler())
.build();
RestClientAdapter adapter = RestClientAdapter.create(restClient);
return HttpServiceProxyFactory.builderFor(adapter)
.customArgumentResolver(new TenantIdArgumentResolver())
.customArgumentResolver(new ApiVersionArgumentResolver())
.embeddedValueResolver(new ServiceUrlResolver(discoveryProperties))
.build();
}
@Bean
public ClientHttpRequestFactory clientHttpRequestFactory() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
// Connection pool configuration
PoolingHttpClientConnectionManager connectionManager =
new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(100);
connectionManager.setDefaultMaxPerRoute(20);
HttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setKeepAliveStrategy(new DefaultConnectionKeepAliveStrategy())
.build();
factory.setHttpClient(httpClient);
factory.setConnectTimeout(Duration.ofSeconds(5));
factory.setReadTimeout(Duration.ofSeconds(30));
return factory;
}
// Custom argument resolver for multi-tenancy
static class TenantIdArgumentResolver implements HttpServiceArgumentResolver {
@Override
public boolean resolve(Object argument, MethodParameter parameter,
HttpRequestValues.Builder requestValues) {
if (parameter.hasParameterAnnotation(TenantId.class)) {
String tenantId = SecurityContextHolder.getContext()
.getAuthentication()
.getDetails()
.getTenantId();
requestValues.addHeader("X-Tenant-Id", tenantId);
return true;
}
return false;
}
}
}
Real-World Example: E-commerce Microservices#
Let’s create a comprehensive example showing how @ImportHttpServices
simplifies microservice communication [2]:
// 1. Define service interfaces for various microservices
public interface InventoryServiceClient {
@GetExchange("/inventory/products/{productId}")
InventoryStatus checkInventory(@PathVariable Long productId);
@PostExchange("/inventory/reserve")
ReservationResponse reserveItems(@RequestBody ReservationRequest request);
@DeleteExchange("/inventory/reservations/{reservationId}")
void cancelReservation(@PathVariable String reservationId);
@PostExchange("/inventory/deduct")
void deductInventory(@RequestBody DeductionRequest request);
}
public interface PricingServiceClient {
@PostExchange("/pricing/calculate")
PricingResponse calculatePrice(@RequestBody PricingRequest request);
@GetExchange("/pricing/promotions")
List<Promotion> getActivePromotions(@RequestParam(required = false) String category);
@PostExchange("/pricing/apply-discount")
DiscountResponse applyDiscount(@RequestBody DiscountRequest request);
}
public interface ShippingServiceClient {
@PostExchange("/shipping/rates")
List<ShippingRate> calculateShippingRates(@RequestBody ShippingRateRequest request);
@PostExchange("/shipping/book")
ShipmentBooking bookShipment(@RequestBody BookingRequest request);
@GetExchange("/shipping/track/{trackingNumber}")
TrackingInfo trackShipment(@PathVariable String trackingNumber);
}
// 2. Configure all HTTP services with resilience patterns
@Configuration
@ImportHttpServices({
@HttpService(value = InventoryServiceClient.class, url = "${services.inventory.url}"),
@HttpService(value = PricingServiceClient.class, url = "${services.pricing.url}"),
@HttpService(value = ShippingServiceClient.class, url = "${services.shipping.url}"),
@HttpService(value = PaymentServiceClient.class, url = "${services.payment.url}")
})
public class MicroservicesConfiguration {
@Bean
@Primary
public RestClient.Builder resilientRestClientBuilder(
MeterRegistry meterRegistry,
CircuitBreakerRegistry circuitBreakerRegistry) {
return RestClient.builder()
.requestFactory(() -> instrumentedRequestFactory(meterRegistry))
.defaultRequest(request -> {
request.header("X-Request-ID", UUID.randomUUID().toString());
request.header("X-Timestamp", Instant.now().toString());
})
.requestInterceptor(new CircuitBreakerInterceptor(circuitBreakerRegistry))
.requestInterceptor(new MetricsInterceptor(meterRegistry));
}
private ClientHttpRequestFactory instrumentedRequestFactory(MeterRegistry registry) {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
// Configure connection pooling
PoolingHttpClientConnectionManager connectionManager =
PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(200)
.setMaxConnPerRoute(50)
.build();
// Build HTTP client with retry handler
HttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setRetryHandler(new DefaultHttpRequestRetryHandler(3, true))
.addExecInterceptorFirst("metrics", new MetricsExecChainHandler(registry))
.build();
factory.setHttpClient(httpClient);
factory.setConnectTimeout(Duration.ofSeconds(5));
factory.setReadTimeout(Duration.ofSeconds(30));
return factory;
}
}
// 3. Implement business logic using the HTTP service clients
@Service
@Slf4j
public class CheckoutService {
private final InventoryServiceClient inventoryService;
private final PricingServiceClient pricingService;
private final ShippingServiceClient shippingService;
private final PaymentServiceClient paymentService;
private final OrderRepository orderRepository;
public CheckoutService(
InventoryServiceClient inventoryService,
PricingServiceClient pricingService,
ShippingServiceClient shippingService,
PaymentServiceClient paymentService,
OrderRepository orderRepository) {
this.inventoryService = inventoryService;
this.pricingService = pricingService;
this.shippingService = shippingService;
this.paymentService = paymentService;
this.orderRepository = orderRepository;
}
@Transactional
public CheckoutResult processCheckout(CheckoutRequest request) {
log.info("Processing checkout for user: {}", request.getUserId());
try {
// 1. Validate inventory availability
List<ReservationItem> items = validateAndReserveInventory(request.getItems());
String reservationId = items.get(0).getReservationId();
// 2. Calculate pricing with promotions
PricingResponse pricing = calculateTotalPrice(request);
// 3. Calculate shipping rates
List<ShippingRate> shippingRates = shippingService.calculateShippingRates(
ShippingRateRequest.builder()
.items(request.getItems())
.destination(request.getShippingAddress())
.build()
);
// 4. Create order
Order order = createOrder(request, pricing, shippingRates.get(0));
// 5. Process payment
PaymentResponse paymentResponse = processPayment(order, request.getPaymentMethod());
if (paymentResponse.isSuccessful()) {
// 6. Confirm inventory deduction
inventoryService.deductInventory(
DeductionRequest.builder()
.reservationId(reservationId)
.orderId(order.getId())
.build()
);
// 7. Book shipment
ShipmentBooking shipment = shippingService.bookShipment(
BookingRequest.builder()
.orderId(order.getId())
.items(request.getItems())
.shippingAddress(request.getShippingAddress())
.shippingMethod(request.getShippingMethod())
.build()
);
order.setTrackingNumber(shipment.getTrackingNumber());
order.setStatus(OrderStatus.CONFIRMED);
orderRepository.save(order);
return CheckoutResult.success(order, shipment);
} else {
// Rollback inventory reservation
inventoryService.cancelReservation(reservationId);
throw new PaymentFailedException("Payment failed: " + paymentResponse.getReason());
}
} catch (Exception e) {
log.error("Checkout failed", e);
throw new CheckoutException("Failed to process checkout", e);
}
}
private List<ReservationItem> validateAndReserveInventory(List<CartItem> items) {
// Check inventory for all items
List<CompletableFuture<InventoryStatus>> inventoryChecks = items.stream()
.map(item -> CompletableFuture.supplyAsync(() ->
inventoryService.checkInventory(item.getProductId())
))
.toList();
// Wait for all checks to complete
CompletableFuture.allOf(inventoryChecks.toArray(new CompletableFuture[0])).join();
// Validate availability
for (int i = 0; i < items.size(); i++) {
InventoryStatus status = inventoryChecks.get(i).join();
if (status.getAvailableQuantity() < items.get(i).getQuantity()) {
throw new InsufficientInventoryException(
"Insufficient inventory for product: " + items.get(i).getProductId()
);
}
}
// Reserve inventory
ReservationResponse reservation = inventoryService.reserveItems(
ReservationRequest.builder()
.items(items)
.duration(Duration.ofMinutes(15))
.build()
);
return reservation.getReservedItems();
}
}
Error Handling and Resilience#
@Component
public class GlobalErrorHandler implements ResponseErrorHandler {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return response.getStatusCode().isError();
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
HttpStatusCode statusCode = response.getStatusCode();
String body = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
if (statusCode.is4xxClientError()) {
ErrorResponse errorResponse = parseErrorResponse(body);
switch (statusCode.value()) {
case 400 -> throw new BadRequestException(errorResponse.getMessage());
case 401 -> throw new UnauthorizedException("Authentication required");
case 403 -> throw new ForbiddenException("Access denied");
case 404 -> throw new ResourceNotFoundException(errorResponse.getMessage());
case 409 -> throw new ConflictException(errorResponse.getMessage());
default -> throw new ClientException(
"Client error: " + statusCode + " - " + errorResponse.getMessage()
);
}
} else if (statusCode.is5xxServerError()) {
throw new ServiceUnavailableException(
"Service error: " + statusCode + " - " + body
);
}
}
private ErrorResponse parseErrorResponse(String body) {
try {
return new ObjectMapper().readValue(body, ErrorResponse.class);
} catch (Exception e) {
return new ErrorResponse("Unknown error", body);
}
}
}
// Circuit breaker interceptor
public class CircuitBreakerInterceptor implements ClientHttpRequestInterceptor {
private final CircuitBreakerRegistry registry;
public CircuitBreakerInterceptor(CircuitBreakerRegistry registry) {
this.registry = registry;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String serviceName = extractServiceName(request.getURI());
CircuitBreaker circuitBreaker = registry.circuitBreaker(serviceName);
return circuitBreaker.executeSupplier(() -> {
try {
return execution.execute(request, body);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
private String extractServiceName(URI uri) {
return uri.getHost().split("\\.")[0];
}
}
Testing HTTP Service Proxies#
@SpringBootTest
@AutoConfigureMockRestServiceServer
class HttpServiceProxyIntegrationTest {
@Autowired
private UserServiceClient userServiceClient;
@Autowired
private MockRestServiceServer mockServer;
@Test
void testUserServiceClient() {
// Given
User expectedUser = new User(1L, "John Doe", "[email protected]");
mockServer.expect(requestTo("/users/1"))
.andExpect(method(HttpMethod.GET))
.andExpect(header("X-Application-Name", "api-gateway"))
.andRespond(withSuccess(
"""
{
"id": 1,
"name": "John Doe",
"email": "john@example.com"
}
""",
MediaType.APPLICATION_JSON
));
// When
User actualUser = userServiceClient.findById(1L);
// Then
assertThat(actualUser).isEqualTo(expectedUser);
mockServer.verify();
}
@Test
void testErrorHandling() {
// Given
mockServer.expect(requestTo("/users/999"))
.andExpect(method(HttpMethod.GET))
.andRespond(withStatus(HttpStatus.NOT_FOUND)
.body("""
{
"error": "User not found",
"message": "No user exists with id: 999"
}
""")
.contentType(MediaType.APPLICATION_JSON)
);
// When/Then
assertThatThrownBy(() -> userServiceClient.findById(999L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("No user exists with id: 999");
mockServer.verify();
}
}
// Contract testing with Pact
@ExtendWith(PactConsumerTestExt.class)
class UserServiceClientPactTest {
@Pact(consumer = "api-gateway", provider = "user-service")
public RequestResponsePact createUserPact(PactDslWithProvider builder) {
return builder
.given("User with ID 1 exists")
.uponReceiving("Get user by ID")
.path("/users/1")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.integerType("id", 1)
.stringType("name", "John Doe")
.stringType("email", "[email protected]")
)
.toPact();
}
@Test
@PactTestFor(providerName = "user-service", port = "8080")
void testGetUser(MockServer mockServer) {
RestClient restClient = RestClient.builder()
.baseUrl(mockServer.getUrl())
.build();
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
UserServiceClient client = factory.createClient(UserServiceClient.class);
User user = client.findById(1L);
assertThat(user.getId()).isEqualTo(1L);
assertThat(user.getName()).isEqualTo("John Doe");
assertThat(user.getEmail()).isEqualTo("[email protected]");
}
}
The @ImportHttpServices
annotation represents a significant advancement in Spring’s HTTP client capabilities, making microservice communication more declarative, type-safe, and maintainable [1] [2]. By combining it with Spring’s ecosystem of resilience patterns, monitoring, and testing tools, you can build robust distributed systems with minimal boilerplate code.
๐ Wrapping Up#
There you have it! Spring Boot 4.0.0 is shaping up to be an exciting release with tons of developer-friendly features. From elegant API versioning to rock-solid null safety and modern HTTP clients, there’s something for everyone.
Remember, this is still a preview release, so:
- ๐งช Test thoroughly in non-production environments
- ๐ Keep an eye on the official documentation
- ๐ Report any bugs you find to help the community
- ๐ฏ Start planning your migration strategy
Useful Resources:#
Happy coding, and may your null pointers be forever banished! ๐โจ
Have questions or found something cool in Spring Boot 4.0.0? Drop a comment below or reach out on Twitter! Let’s explore this together! ๐ค