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)