Introduction
In modern microservices architecture, service-to-service communication is fundamental to building distributed applications. Services need to communicate with each other to perform full CRUD (Create, Read, Update, Delete) operations, exchange data, and maintain system cohesion. This communication typically happens over HTTP using REST APIs, where one service acts as a client calling another service's endpoints.
Spring Boot offers several approaches for making HTTP calls to external services. The most popular methods include using Spring Cloud OpenFeign for declarative REST clients, or traditional approaches like RestTemplate
and WebClient
for more programmatic control. Each approach has its own advantages and use cases.
This blog post will explore both approaches through a practical example with complete CRUD operations, helping you understand when and how to use each method effectively.
Scenario Setup
Let's consider a comprehensive microservices scenario where we have:
- OrderService: Manages customer orders and needs to perform CRUD operations on users
- UserService: Handles user information and profiles with full REST API
Our example will demonstrate all CRUD operations:
- CREATE: Adding new users
- READ: Fetching user details by ID and listing all users
- UPDATE: Modifying existing user information
- DELETE: Removing users from the system
The UserService
exposes these REST endpoints:
-
POST /api/users
- Create a new user -
GET /api/users/{userId}
- Get user by ID -
GET /api/users
- Get all users -
PUT /api/users/{userId}
- Update user by ID -
DELETE /api/users/{userId}
- Delete user by ID
Approach 1 – Using Spring Cloud OpenFeign
Spring Cloud OpenFeign provides a declarative approach to create REST clients. It generates the implementation at runtime based on interface annotations, reducing boilerplate code significantly.
Dependencies
Maven (pom.xml
):
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2023.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
Gradle (build.gradle
):
dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.boot:spring-boot-starter-validation' } dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.0" } }
Main Application Class
package com.example.orderservice; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication @EnableFeignClients public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } }
User Models
package com.example.orderservice.model; import com.fasterxml.jackson.annotation.JsonFormat; import javax.validation.constraints.Email; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Size; import java.time.LocalDateTime; public class User { private Long id; @NotBlank(message = "Name is required") @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") private String name; @NotBlank(message = "Email is required") @Email(message = "Email should be valid") private String email; private String phone; private String address; @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime createdAt; @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime updatedAt; // Constructors public User() {} public User(String name, String email, String phone, String address) { this.name = name; this.email = email; this.phone = phone; this.address = address; this.createdAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now(); } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; this.updatedAt = LocalDateTime.now(); } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; this.updatedAt = LocalDateTime.now(); } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; this.updatedAt = LocalDateTime.now(); } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; this.updatedAt = LocalDateTime.now(); } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } @Override public String toString() { return "User{id=" + id + ", name='" + name + "', email='" + email + "'}"; } }
package com.example.orderservice.dto; import javax.validation.constraints.Email; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Size; public class CreateUserRequest { @NotBlank(message = "Name is required") @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") private String name; @NotBlank(message = "Email is required") @Email(message = "Email should be valid") private String email; private String phone; private String address; // Constructors public CreateUserRequest() {} public CreateUserRequest(String name, String email, String phone, String address) { this.name = name; this.email = email; this.phone = phone; this.address = address; } // Getters and Setters public String getName() { return name; } public void setName(String name) { this.name = name; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } }
package com.example.orderservice.dto; import javax.validation.constraints.Email; import javax.validation.constraints.Size; public class UpdateUserRequest { @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") private String name; @Email(message = "Email should be valid") private String email; private String phone; private String address; // Constructors public UpdateUserRequest() {} // Getters and Setters public String getName() { return name; } public void setName(String name) { this.name = name; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } }
Feign Client Interface with Full CRUD
package com.example.orderservice.client; import com.example.orderservice.dto.CreateUserRequest; import com.example.orderservice.dto.UpdateUserRequest; import com.example.orderservice.model.User; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.*; import java.util.List; @FeignClient(name = "user-service", url = "${user-service.url}") public interface UserServiceClient { @PostMapping("/api/users") User createUser(@RequestBody CreateUserRequest request); @GetMapping("/api/users/{userId}") User getUserById(@PathVariable("userId") Long userId); @GetMapping("/api/users") List<User> getAllUsers(); @GetMapping("/api/users") List<User> getAllUsers(@RequestParam(value = "page", defaultValue = "0") int page, @RequestParam(value = "size", defaultValue = "10") int size); @PutMapping("/api/users/{userId}") User updateUser(@PathVariable("userId") Long userId, @RequestBody UpdateUserRequest request); @DeleteMapping("/api/users/{userId}") void deleteUser(@PathVariable("userId") Long userId); }
Service Class Using Feign Client - Full CRUD Operations
package com.example.orderservice.service; import com.example.orderservice.client.UserServiceClient; import com.example.orderservice.dto.CreateUserRequest; import com.example.orderservice.dto.UpdateUserRequest; import com.example.orderservice.model.User; import feign.FeignException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; @Service public class OrderService { private static final Logger logger = LoggerFactory.getLogger(OrderService.class); @Autowired private UserServiceClient userServiceClient; // CREATE Operation public User createUser(CreateUserRequest request) { try { User createdUser = userServiceClient.createUser(request); logger.info("Successfully created user: {}", createdUser.getName()); return createdUser; } catch (FeignException.BadRequest e) { logger.error("Invalid user data: {}", e.getMessage()); throw new RuntimeException("Invalid user data provided"); } catch (FeignException.Conflict e) { logger.error("User already exists: {}", e.getMessage()); throw new RuntimeException("User with this email already exists"); } catch (FeignException e) { logger.error("Error creating user: {}", e.getMessage()); throw new RuntimeException("Failed to create user due to service error"); } } // READ Operations public User getUserById(Long userId) { try { User user = userServiceClient.getUserById(userId); logger.info("Retrieved user: {}", user.getName()); return user; } catch (FeignException.NotFound e) { logger.error("User not found for ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (FeignException e) { logger.error("Error retrieving user: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve user due to service error"); } } public List<User> getAllUsers() { try { List<User> users = userServiceClient.getAllUsers(); logger.info("Retrieved {} users", users.size()); return users; } catch (FeignException e) { logger.error("Error retrieving users: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve users due to service error"); } } public List<User> getAllUsers(int page, int size) { try { List<User> users = userServiceClient.getAllUsers(page, size); logger.info("Retrieved {} users for page {} with size {}", users.size(), page, size); return users; } catch (FeignException e) { logger.error("Error retrieving paginated users: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve users due to service error"); } } // UPDATE Operation public User updateUser(Long userId, UpdateUserRequest request) { try { User updatedUser = userServiceClient.updateUser(userId, request); logger.info("Successfully updated user: {}", updatedUser.getName()); return updatedUser; } catch (FeignException.NotFound e) { logger.error("User not found for update, ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (FeignException.BadRequest e) { logger.error("Invalid update data: {}", e.getMessage()); throw new RuntimeException("Invalid user data provided for update"); } catch (FeignException e) { logger.error("Error updating user: {}", e.getMessage()); throw new RuntimeException("Failed to update user due to service error"); } } // DELETE Operation public void deleteUser(Long userId) { try { userServiceClient.deleteUser(userId); logger.info("Successfully deleted user with ID: {}", userId); } catch (FeignException.NotFound e) { logger.error("User not found for deletion, ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (FeignException e) { logger.error("Error deleting user: {}", e.getMessage()); throw new RuntimeException("Failed to delete user due to service error"); } } // Business Logic Methods public void processOrderWithUserCreation(Long orderId, CreateUserRequest userRequest) { User user = createUser(userRequest); logger.info("Processing order {} for newly created user: {}", orderId, user.getId()); // Order processing logic here } public void processOrderWithUserValidation(Long orderId, Long userId) { User user = getUserById(userId); validateUser(user); logger.info("Processing order {} for validated user: {}", orderId, user.getName()); // Order processing logic here } private void validateUser(User user) { if (user.getEmail() == null || user.getEmail().isEmpty()) { throw new RuntimeException("User email is required for order processing"); } if (user.getName() == null || user.getName().trim().isEmpty()) { throw new RuntimeException("User name is required for order processing"); } } }
REST Controller for Testing
package com.example.orderservice.controller; import com.example.orderservice.dto.CreateUserRequest; import com.example.orderservice.dto.UpdateUserRequest; import com.example.orderservice.model.User; import com.example.orderservice.service.OrderService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import java.util.List; @RestController @RequestMapping("/api/orders") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/users") public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) { User user = orderService.createUser(request); return new ResponseEntity<>(user, HttpStatus.CREATED); } @GetMapping("/users/{userId}") public ResponseEntity<User> getUserById(@PathVariable Long userId) { User user = orderService.getUserById(userId); return ResponseEntity.ok(user); } @GetMapping("/users") public ResponseEntity<List<User>> getAllUsers( @RequestParam(value = "page", defaultValue = "0") int page, @RequestParam(value = "size", defaultValue = "10") int size) { List<User> users = orderService.getAllUsers(page, size); return ResponseEntity.ok(users); } @PutMapping("/users/{userId}") public ResponseEntity<User> updateUser(@PathVariable Long userId, @Valid @RequestBody UpdateUserRequest request) { User user = orderService.updateUser(userId, request); return ResponseEntity.ok(user); } @DeleteMapping("/users/{userId}") public ResponseEntity<Void> deleteUser(@PathVariable Long userId) { orderService.deleteUser(userId); return ResponseEntity.noContent().build(); } }
Configuration Properties
application.yml
:
server: port: 8080 user-service: url: http://localhost:8081 feign: client: config: user-service: connect-timeout: 5000 read-timeout: 10000 logger-level: full error-decoder: feign.codec.ErrorDecoder.Default logging: level: com.example.orderservice.client: DEBUG feign: DEBUG
Approach 2 – Without Feign (Plain Spring)
Using RestTemplate - Full CRUD
RestTemplate is the traditional synchronous HTTP client in Spring. Although in maintenance mode since Spring 5, it's still widely used and supported.
Configuration Class
package com.example.orderservice.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestTemplate; import org.springframework.boot.web.client.RestTemplateBuilder; import java.time.Duration; @Configuration public class RestConfig { @Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { return builder .setConnectTimeout(Duration.ofSeconds(5)) .setReadTimeout(Duration.ofSeconds(10)) .build(); } }
Service Implementation with RestTemplate - Full CRUD
package com.example.orderservice.service; import com.example.orderservice.dto.CreateUserRequest; import com.example.orderservice.dto.UpdateUserRequest; import com.example.orderservice.model.User; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import java.util.List; @Service public class OrderServiceRestTemplate { private static final Logger logger = LoggerFactory.getLogger(OrderServiceRestTemplate.class); @Autowired private RestTemplate restTemplate; @Value("${user-service.url}") private String userServiceUrl; // CREATE Operation public User createUser(CreateUserRequest request) { try { String url = userServiceUrl + "/api/users"; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<CreateUserRequest> entity = new HttpEntity<>(request, headers); ResponseEntity<User> response = restTemplate.exchange( url, HttpMethod.POST, entity, User.class); User createdUser = response.getBody(); logger.info("Successfully created user: {}", createdUser.getName()); return createdUser; } catch (HttpClientErrorException.BadRequest e) { logger.error("Invalid user data: {}", e.getMessage()); throw new RuntimeException("Invalid user data provided"); } catch (HttpClientErrorException.Conflict e) { logger.error("User already exists: {}", e.getMessage()); throw new RuntimeException("User with this email already exists"); } catch (ResourceAccessException e) { logger.error("Connection error to user service: {}", e.getMessage()); throw new RuntimeException("User service unavailable"); } catch (Exception e) { logger.error("Unexpected error creating user: {}", e.getMessage()); throw new RuntimeException("Failed to create user"); } } // READ Operations public User getUserById(Long userId) { try { String url = userServiceUrl + "/api/users/" + userId; User user = restTemplate.getForObject(url, User.class); if (user != null) { logger.info("Retrieved user: {}", user.getName()); return user; } else { throw new RuntimeException("User not found"); } } catch (HttpClientErrorException.NotFound e) { logger.error("User not found for ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (ResourceAccessException e) { logger.error("Connection error to user service: {}", e.getMessage()); throw new RuntimeException("User service unavailable"); } catch (Exception e) { logger.error("Unexpected error retrieving user: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve user"); } } public List<User> getAllUsers() { try { String url = userServiceUrl + "/api/users"; ResponseEntity<List<User>> response = restTemplate.exchange( url, HttpMethod.GET, null, new ParameterizedTypeReference<List<User>>() {}); List<User> users = response.getBody(); logger.info("Retrieved {} users", users != null ? users.size() : 0); return users; } catch (ResourceAccessException e) { logger.error("Connection error to user service: {}", e.getMessage()); throw new RuntimeException("User service unavailable"); } catch (Exception e) { logger.error("Unexpected error retrieving users: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve users"); } } public List<User> getAllUsers(int page, int size) { try { String url = userServiceUrl + "/api/users?page=" + page + "&size=" + size; ResponseEntity<List<User>> response = restTemplate.exchange( url, HttpMethod.GET, null, new ParameterizedTypeReference<List<User>>() {}); List<User> users = response.getBody(); logger.info("Retrieved {} users for page {} with size {}", users != null ? users.size() : 0, page, size); return users; } catch (ResourceAccessException e) { logger.error("Connection error to user service: {}", e.getMessage()); throw new RuntimeException("User service unavailable"); } catch (Exception e) { logger.error("Unexpected error retrieving paginated users: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve users"); } } // UPDATE Operation public User updateUser(Long userId, UpdateUserRequest request) { try { String url = userServiceUrl + "/api/users/" + userId; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<UpdateUserRequest> entity = new HttpEntity<>(request, headers); ResponseEntity<User> response = restTemplate.exchange( url, HttpMethod.PUT, entity, User.class); User updatedUser = response.getBody(); logger.info("Successfully updated user: {}", updatedUser.getName()); return updatedUser; } catch (HttpClientErrorException.NotFound e) { logger.error("User not found for update, ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (HttpClientErrorException.BadRequest e) { logger.error("Invalid update data: {}", e.getMessage()); throw new RuntimeException("Invalid user data provided for update"); } catch (ResourceAccessException e) { logger.error("Connection error to user service: {}", e.getMessage()); throw new RuntimeException("User service unavailable"); } catch (Exception e) { logger.error("Unexpected error updating user: {}", e.getMessage()); throw new RuntimeException("Failed to update user"); } } // DELETE Operation public void deleteUser(Long userId) { try { String url = userServiceUrl + "/api/users/" + userId; restTemplate.delete(url); logger.info("Successfully deleted user with ID: {}", userId); } catch (HttpClientErrorException.NotFound e) { logger.error("User not found for deletion, ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (ResourceAccessException e) { logger.error("Connection error to user service: {}", e.getMessage()); throw new RuntimeException("User service unavailable"); } catch (Exception e) { logger.error("Unexpected error deleting user: {}", e.getMessage()); throw new RuntimeException("Failed to delete user"); } } }
Using WebClient - Full CRUD
WebClient is the modern reactive HTTP client introduced in Spring 5, designed for both synchronous and asynchronous operations.
Configuration Class
package com.example.orderservice.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; import java.time.Duration; @Configuration public class WebClientConfig { @Value("${user-service.url}") private String userServiceUrl; @Bean public WebClient webClient() { return WebClient.builder() .baseUrl(userServiceUrl) .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024)) .build(); } }
Service Implementation with WebClient - Full CRUD
package com.example.orderservice.service; import com.example.orderservice.dto.CreateUserRequest; import com.example.orderservice.dto.UpdateUserRequest; import com.example.orderservice.model.User; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; import java.time.Duration; import java.util.List; @Service public class OrderServiceWebClient { private static final Logger logger = LoggerFactory.getLogger(OrderServiceWebClient.class); @Autowired private WebClient webClient; // CREATE Operation (Synchronous) public User createUser(CreateUserRequest request) { try { User createdUser = webClient.post() .uri("/api/users") .bodyValue(request) .retrieve() .bodyToMono(User.class) .timeout(Duration.ofSeconds(10)) .block(); logger.info("Successfully created user: {}", createdUser.getName()); return createdUser; } catch (WebClientResponseException.BadRequest e) { logger.error("Invalid user data: {}", e.getMessage()); throw new RuntimeException("Invalid user data provided"); } catch (WebClientResponseException.Conflict e) { logger.error("User already exists: {}", e.getMessage()); throw new RuntimeException("User with this email already exists"); } catch (Exception e) { logger.error("Error creating user: {}", e.getMessage()); throw new RuntimeException("Failed to create user due to service error"); } } // CREATE Operation (Reactive) public Mono<User> createUserReactive(CreateUserRequest request) { return webClient.post() .uri("/api/users") .bodyValue(request) .retrieve() .bodyToMono(User.class) .timeout(Duration.ofSeconds(10)) .doOnSuccess(user -> logger.info("Successfully created user: {}", user.getName())) .onErrorResume(WebClientResponseException.BadRequest.class, e -> { logger.error("Invalid user data: {}", e.getMessage()); return Mono.error(new RuntimeException("Invalid user data provided")); }) .onErrorResume(WebClientResponseException.Conflict.class, e -> { logger.error("User already exists: {}", e.getMessage()); return Mono.error(new RuntimeException("User with this email already exists")); }) .onErrorResume(Exception.class, e -> { logger.error("Error creating user: {}", e.getMessage()); return Mono.error(new RuntimeException("Failed to create user")); }); } // READ Operations public User getUserById(Long userId) { try { User user = webClient.get() .uri("/api/users/{userId}", userId) .retrieve() .bodyToMono(User.class) .timeout(Duration.ofSeconds(10)) .block(); if (user != null) { logger.info("Retrieved user: {}", user.getName()); return user; } else { throw new RuntimeException("User not found"); } } catch (WebClientResponseException.NotFound e) { logger.error("User not found for ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (Exception e) { logger.error("Error retrieving user: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve user due to service error"); } } public Mono<User> getUserByIdReactive(Long userId) { return webClient.get() .uri("/api/users/{userId}", userId) .retrieve() .bodyToMono(User.class) .timeout(Duration.ofSeconds(10)) .doOnSuccess(user -> logger.info("Retrieved user: {}", user.getName())) .onErrorResume(WebClientResponseException.NotFound.class, e -> { logger.error("User not found for ID: {}", userId); return Mono.error(new RuntimeException("User not found with ID: " + userId)); }) .onErrorResume(Exception.class, e -> { logger.error("Error retrieving user: {}", e.getMessage()); return Mono.error(new RuntimeException("Failed to retrieve user")); }); } public List<User> getAllUsers() { try { List<User> users = webClient.get() .uri("/api/users") .retrieve() .bodyToMono(new ParameterizedTypeReference<List<User>>() {}) .timeout(Duration.ofSeconds(10)) .block(); logger.info("Retrieved {} users", users != null ? users.size() : 0); return users; } catch (Exception e) { logger.error("Error retrieving users: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve users due to service error"); } } public Mono<List<User>> getAllUsersReactive() { return webClient.get() .uri("/api/users") .retrieve() .bodyToMono(new ParameterizedTypeReference<List<User>>() {}) .timeout(Duration.ofSeconds(10)) .doOnSuccess(users -> logger.info("Retrieved {} users", users != null ? users.size() : 0)) .onErrorResume(Exception.class, e -> { logger.error("Error retrieving users: {}", e.getMessage()); return Mono.error(new RuntimeException("Failed to retrieve users")); }); } public List<User> getAllUsers(int page, int size) { try { List<User> users = webClient.get() .uri(uriBuilder -> uriBuilder .path("/api/users") .queryParam("page", page) .queryParam("size", size) .build()) .retrieve() .bodyToMono(new ParameterizedTypeReference<List<User>>() {}) .timeout(Duration.ofSeconds(10)) .block(); logger.info("Retrieved {} users for page {} with size {}", users != null ? users.size() : 0, page, size); return users; } catch (Exception e) { logger.error("Error retrieving paginated users: {}", e.getMessage()); throw new RuntimeException("Failed to retrieve users due to service error"); } } // UPDATE Operation public User updateUser(Long userId, UpdateUserRequest request) { try { User updatedUser = webClient.put() .uri("/api/users/{userId}", userId) .bodyValue(request) .retrieve() .bodyToMono(User.class) .timeout(Duration.ofSeconds(10)) .block(); logger.info("Successfully updated user: {}", updatedUser.getName()); return updatedUser; } catch (WebClientResponseException.NotFound e) { logger.error("User not found for update, ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (WebClientResponseException.BadRequest e) { logger.error("Invalid update data: {}", e.getMessage()); throw new RuntimeException("Invalid user data provided for update"); } catch (Exception e) { logger.error("Error updating user: {}", e.getMessage()); throw new RuntimeException("Failed to update user due to service error"); } } public Mono<User> updateUserReactive(Long userId, UpdateUserRequest request) { return webClient.put() .uri("/api/users/{userId}", userId) .bodyValue(request) .retrieve() .bodyToMono(User.class) .timeout(Duration.ofSeconds(10)) .doOnSuccess(user -> logger.info("Successfully updated user: {}", user.getName())) .onErrorResume(WebClientResponseException.NotFound.class, e -> { logger.error("User not found for update, ID: {}", userId); return Mono.error(new RuntimeException("User not found with ID: " + userId)); }) .onErrorResume(WebClientResponseException.BadRequest.class, e -> { logger.error("Invalid update data: {}", e.getMessage()); return Mono.error(new RuntimeException("Invalid user data provided for update")); }) .onErrorResume(Exception.class, e -> { logger.error("Error updating user: {}", e.getMessage()); return Mono.error(new RuntimeException("Failed to update user")); }); } // DELETE Operation public void deleteUser(Long userId) { try { webClient.delete() .uri("/api/users/{userId}", userId) .retrieve() .toBodilessEntity() .timeout(Duration.ofSeconds(10)) .block(); logger.info("Successfully deleted user with ID: {}", userId); } catch (WebClientResponseException.NotFound e) { logger.error("User not found for deletion, ID: {}", userId); throw new RuntimeException("User not found with ID: " + userId); } catch (Exception e) { logger.error("Error deleting user: {}", e.getMessage()); throw new RuntimeException("Failed to delete user due to service error"); } } public Mono<Void> deleteUserReactive(Long userId) { return webClient.delete() .uri("/api/users/{userId}", userId) .retrieve() .toBodilessEntity() .timeout(Duration.ofSeconds(10)) .doOnSuccess(response -> logger.info("Successfully deleted user with ID: {}", userId)) .onErrorResume(WebClientResponseException.NotFound.class, e -> { logger.error("User not found for deletion, ID: {}", userId); return Mono.error(new RuntimeException("User not found with ID: " + userId)); }) .onErrorResume(Exception.class, e -> { logger.error("Error deleting user: {}", e.getMessage()); return Mono.error(new RuntimeException("Failed to delete user")); }) .then(); } }
Dependencies for WebClient
Add these to your pom.xml
if using WebClient:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>
Error Handling Configuration
package com.example.orderservice.exception; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler(RuntimeException.class) public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException e) { logger.error("Runtime exception: {}", e.getMessage()); Map<String, Object> error = new HashMap<>(); error.put("timestamp", LocalDateTime.now()); error.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); error.put("error", "Internal Server Error"); error.put("message", e.getMessage()); return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, Object>> handleValidationException(MethodArgumentNotValidException e) { Map<String, Object> error = new HashMap<>(); Map<String, String> validationErrors = new HashMap<>(); e.getBindingResult().getFieldErrors().forEach(fieldError -> { validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); }); error.put("timestamp", LocalDateTime.now()); error.put("status", HttpStatus.BAD_REQUEST.value()); error.put("error", "Validation Failed"); error.put("validationErrors", validationErrors); return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); } }
Comparison Table - Full CRUD Operations
Aspect | Spring Cloud OpenFeign | RestTemplate | WebClient |
---|---|---|---|
Code Complexity | Low - declarative interfaces | High - manual request building | Medium - fluent API with reactive support |
CRUD Implementation | Simple method annotations | Verbose HTTP method calls | Clean functional style |
Request Body Handling | Automatic with @RequestBody | Manual HttpEntity creation | Automatic with bodyValue() |
Response Deserialization | Automatic JSON to POJO | Manual with ParameterizedTypeReference | Automatic with bodyToMono() |
Error Handling | Built-in FeignException types | Manual HTTP status code checking | Reactive error handling with onErrorResume |
Pagination Support | Simple @RequestParam mapping | Manual URL parameter building | Clean URI builder support |
Performance (CRUD) | Good - connection pooling | Good - synchronous blocking | Excellent - non-blocking I/O |
Testing CRUD Operations | Easy interface mocking | Standard RestTemplate mocking | Reactive testing with StepVerifier |
Configuration Overhead | Minimal - just @EnableFeignClients | Medium - RestTemplate bean setup | Medium - WebClient configuration |
Reactive Support | None - blocking operations only | None - synchronous only | Full reactive and blocking support |
Bulk Operations | Manual loop implementation | Manual loop implementation | Reactive streams with flatMap |
Request Interceptors | Built-in Feign interceptors | RestTemplate interceptors | WebClient filters |
Timeout Configuration | Declarative in properties | Programmatic setup | Per-request timeout support |
Connection Pooling | Automatic with HTTP client | Manual configuration needed | Built-in with Reactor Netty |
Circuit Breaker Integration | Native Spring Cloud support | Manual Hystrix integration | Manual resilience4j integration |
Metrics & Monitoring | Built-in Feign metrics | Manual RestTemplate metrics | Built-in WebClient metrics |
Testing Examples
Testing Feign Client
package com.example.orderservice.service; import com.example.orderservice.client.UserServiceClient; import com.example.orderservice.dto.CreateUserRequest; import com.example.orderservice.model.User; import feign.FeignException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MockitoExtension.class) public class OrderServiceTest { @Mock private UserServiceClient userServiceClient; @InjectMocks private OrderService orderService; @Test public void testCreateUser_Success() { // Given CreateUserRequest request = new CreateUserRequest("John Doe", "john@example.com", "1234567890", "123 Main St"); User expectedUser = new User("John Doe", "john@example.com", "1234567890", "123 Main St"); expectedUser.setId(1L); when(userServiceClient.createUser(any(CreateUserRequest.class))).thenReturn(expectedUser); // When User result = orderService.createUser(request); // Then assertNotNull(result); assertEquals("John Doe", result.getName()); assertEquals("john@example.com", result.getEmail()); verify(userServiceClient, times(1)).createUser(request); } @Test public void testGetUserById_UserNotFound() { // Given Long userId = 999L; when(userServiceClient.getUserById(userId)).thenThrow(FeignException.NotFound.class); // When & Then RuntimeException exception = assertThrows(RuntimeException.class, () -> { orderService.getUserById(userId); }); assertTrue(exception.getMessage().contains("User not found with ID: 999")); } @Test public void testDeleteUser_Success() { // Given Long userId = 1L; doNothing().when(userServiceClient).deleteUser(userId); // When orderService.deleteUser(userId); // Then verify(userServiceClient, times(1)).deleteUser(userId); } }
Integration Testing with WebClient
package com.example.orderservice.service; import com.example.orderservice.dto.CreateUserRequest; import com.example.orderservice.model.User; import com.fasterxml.jackson.databind.ObjectMapper; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.reactive.function.client.WebClient; import reactor.test.StepVerifier; import java.io.IOException; import java.time.Duration; public class OrderServiceWebClientIntegrationTest { private MockWebServer mockWebServer; private OrderServiceWebClient orderService; private ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setup() throws IOException { mockWebServer = new MockWebServer(); mockWebServer.start(); WebClient webClient = WebClient.builder() .baseUrl(mockWebServer.url("/").toString()) .build(); orderService = new OrderServiceWebClient(); // Inject webClient using reflection or setter } @AfterEach void cleanup() throws IOException { mockWebServer.shutdown(); } @Test void testCreateUserReactive_Success() throws Exception { // Given CreateUserRequest request = new CreateUserRequest("Jane Doe", "jane@example.com", "0987654321", "456 Oak St"); User expectedUser = new User("Jane Doe", "jane@example.com", "0987654321", "456 Oak St"); expectedUser.setId(1L); mockWebServer.enqueue(new MockResponse() .setBody(objectMapper.writeValueAsString(expectedUser)) .addHeader("Content-Type", "application/json")); // When & Then StepVerifier.create(orderService.createUserReactive(request)) .expectNext(expectedUser) .verifyComplete(); } @Test void testGetUserByIdReactive_NotFound() { // Given mockWebServer.enqueue(new MockResponse().setResponseCode(404)); // When & Then StepVerifier.create(orderService.getUserByIdReactive(999L)) .expectErrorMatches(throwable -> throwable instanceof RuntimeException && throwable.getMessage().contains("User not found with ID: 999")) .verify(Duration.ofSeconds(5)); } }
Performance Considerations
Connection Pooling Configuration
# application.yml feign: httpclient: enabled: true max-connections: 200 max-connections-per-route: 50 connection-timeout: 2000 connection-timer-repeat: 3000 # For WebClient spring: webflux: client: max-in-memory-size: 1MB
Custom Feign Configuration
@Configuration public class FeignConfiguration { @Bean public Retryer retryer() { return new Retryer.Default(100, 3000, 3); } @Bean public ErrorDecoder errorDecoder() { return new CustomErrorDecoder(); } @Bean public RequestInterceptor requestInterceptor() { return template -> { template.header("User-Agent", "OrderService/1.0"); template.header("Accept", "application/json"); }; } }
Conclusion
When to Choose Feign for CRUD Operations
Choose Spring Cloud OpenFeign when:
- Building microservices with comprehensive CRUD operations
- You want declarative, interface-based REST clients with minimal boilerplate
- Your team prefers annotation-driven development
- You need built-in load balancing, circuit breakers, and retry mechanisms
- Working with synchronous request-response patterns across multiple services
- Integration with Spring Cloud ecosystem is important
- You want automatic request/response serialization without manual configuration
When to Choose RestTemplate for CRUD Operations
Choose RestTemplate when:
- Working with legacy Spring applications requiring CRUD functionality
- Your team is familiar with traditional Spring patterns
- You need fine-grained control over HTTP request construction
- Working with simple, straightforward CRUD operations
- You're not ready to adopt reactive programming
- Integration with existing RestTemplate-based code
When to Choose WebClient for CRUD Operations
Choose WebClient when:
- Building reactive applications with non-blocking CRUD operations
- You need both synchronous and asynchronous CRUD patterns
- Performance and scalability are critical for high-volume operations
- You want to handle streaming data or real-time updates
- Building modern, high-throughput applications with complex data flows
- You need advanced HTTP features and extensive customization options
- Working with reactive Spring Security or other reactive components
Best Practices Summary
- Use Feign for declarative microservices communication with minimal effort
- Use RestTemplate for simple, traditional synchronous operations
- Use WebClient for modern, high-performance reactive applications
- Always implement proper error handling and timeout configurations
- Consider connection pooling for high-traffic applications
- Use appropriate testing strategies for each approach
- Monitor and measure performance to make informed decisions
The choice between these approaches depends on your specific architectural requirements, team expertise, performance needs, and whether you're building reactive or traditional Spring applications. Feign provides the most developer-friendly experience for comprehensive CRUD operations, while WebClient offers superior performance and flexibility for modern reactive applications.
Top comments (0)