Enhance Interoperability in API-Based Systems by Streamlining API Responses
A few years ago, we were setting up a microservices system for a major banking and insurance project in the Asia-Pacific region. Dealing with both legacy and new microservices, each managed autonomously by separate teams, we faced challenges with inconsistent API response handling. This led to confusion, specially in BFF services. To address this, we adopted a new approach for new services and gradually transitioned existing ones. After some refinements, we resolved the issues and found that the consistent response handling also facilitated a smooth migration to an event-driven architecture.
During our weekend discussions as fellow architects, the latest conversation reminded me of a helpful approach I suggested — the common APIResponse approach. I decided to write it down for you.
In modern software applications are distributed and delivered by autonomous teams. Hence it is important to ensure a consistent response format across different endpoints is not only considered good practice but also an essential component for building resilient and scalable APIs. One impactful strategy to enforce this uniformity is by incorporating a default APIResponse
for each endpoint.
This article explains the importance of adopting such an approach and illustrates how it contributes to enhancing interoperability within multiple modules in a complex distributed system.
This practice is particularly valuable for autonomous teams relying on the same modules within a complex product, encouraging a more cohesive and efficient development process and developer experience.
You can access the code here:
https://github.com/DIL8654/coding-best-practices/tree/main/api-response-handler
Kindly note that I have utilized ChatGPT to generate all the code snippets based on my conceptual idea. By conveying the general structure and logic, I saved significant time that would have been spent manually writing out the module. Then, I made necessary adjustments to refine and implement the generated code as a functional module.
Why APIResponse wrapper class
Introducing the default APIResponse
for each endpoint is a key practice in this approach. It will be serviced as an envelope of your all API responses. This class encapsulates all the relevant information and the data payload.
It plays an important role in enhancing the comprehension of both external and internal states of the operation for the API consumer.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class APIResponse<T> {
/** The status of the API response, indicating success or failure. */
private String status; /** The HTTP status code associated with the API response. */
private Integer httpStatus; /** A human-readable message providing additional information about the API response. */
private String message; /**
* An internal code or identifier for the API response, used for error identification by
* stakeholders utilizing these APIs. This represents what is happening in the core-responsible module,
* e.g., whether it is handled or a generic response code that helps consumers to handle it.
*/
private String internalCode; /** The payload included in the API response, holding the actual content. */
private T payload; // Other relevant methods goes here.}
Handling Responses in a unified manner
A key advantage of having a default response is its capability to uniformly manage successful and error responses. By incorporating the common approach method across your application, you establish a standardized way of conveying success, badRequest, notFound, and internalServerError according to the standard HTTP errors, enhancing the clarity and interpretation of responses for clients.
Adopting this methodology streamlines error management and establishes a structured framework for effectively communicating issues to clients.
How to Build Success Response:
/**
* Builds an APIResponse for a successful operation.
*
* @param payload The payload to include in the response.
* @param responseMap A map containing response messages.
* @param internalCode The key corresponding to the desired response message.
* @param <T> The type of data to be included in the response.
* @return An APIResponse indicating a successful operation.
*/
public static <T> APIResponse<T> ok(
T payload, Map<String, String> responseMap, String internalCode) {
return APIResponse.<T>builder()
.httpStatus(HttpStatus.OK.value())
.status(UserConstants.SUCCESS)
.message(responseMap.get(internalCode))
.internalCode(internalCode)
.payload(payload)
.build();
}
How to Build Error Response:
/**
* Builds an APIResponse for a bad request operation.
*
* @param payload The payload to include in the response.
* @param responseMap A map containing response messages.
* @param internalCode The key corresponding to the desired response message.
* @param <T> The type of data to be included in the response.
* @return An APIResponse indicating a failed operation.
*/
public static <T> APIResponse<T> badRequest(
T payload, Map<String, String> responseMap, String internalCode) {
return APIResponse.<T>builder()
.httpStatus(HttpStatus.BAD_REQUEST.value())
.status(UserConstants.RESULT_KO)
.message(responseMap.get(internalCode))
.internalCode(internalCode)
.payload(payload)
.build();
}
/**
* Builds an APIResponse for a not found operation.
*
* @param payload The payload to include in the response.
* @param responseMap A map containing response messages.
* @param internalCode The key corresponding to the desired response message.
* @param <T> The type of data to be included in the response.
* @return An APIResponse indicating a failed operation.
*/
public static <T> APIResponse<T> notFound(
T payload, Map<String, String> responseMap, String internalCode) {
return APIResponse.<T>builder()
.httpStatus(HttpStatus.NOT_FOUND.value())
.status(UserConstants.RESULT_KO)
.message(responseMap.get(internalCode))
.internalCode(internalCode)
.payload(payload)
.build();
}/**
* Builds an APIResponse for an internal server error.
*
* @param payload The payload to include in the response.
* @param responseMap A map containing response messages.
* @param internalCode The key corresponding to the desired response message.
* @param <T> The type of data to be included in the response.
* @return An APIResponse indicating a failed operation.
*/
public static <T> APIResponse<T> internalServerError(
T payload, Map<String, String> responseMap, String internalCode) {
return APIResponse.<T>builder()
.httpStatus(HttpStatus.INTERNAL_SERVER_ERROR.value())
.status(UserConstants.RESULT_KO)
.message(responseMap.get(internalCode))
.internalCode(internalCode)
.payload(payload)
.build();
}
Centralize Approach to Maintain Module Response Constants
Consistency must be supported within your respective modules. You have the option to use a centralized component for managing your response constants and error codes within your module, adhering to a format that aligns with your internal module architecture, along with corresponding error messages.
public final class UserConstants {
public static final String SUCCESS = "SUCCESS";
public static final String FAILURE = "FAILURE";
public static final String GENERIC_RESPONSE_CODE = "USER-GENERIC-ERROR";
public static final String USER_RESPONSE_CODE_PREFIX = "USER-";
public static Map<String, String> getUserResponseHashMap() {
return Map.of(
USER_RESPONSE_CODE_PREFIX + "1", "User not found",
USER_RESPONSE_CODE_PREFIX + "2", "User already exists",
USER_RESPONSE_CODE_PREFIX + "3", "User not created",
USER_RESPONSE_CODE_PREFIX + "4", "User not updated",
USER_RESPONSE_CODE_PREFIX + "5", "User not deleted",
USER_RESPONSE_CODE_PREFIX + "6", "User found",
USER_RESPONSE_CODE_PREFIX + "7", "User successfully created",
USER_RESPONSE_CODE_PREFIX + "8", "User successfully updated",
USER_RESPONSE_CODE_PREFIX + "9", "User successfully deleted",
USER_RESPONSE_CODE_PREFIX + "10", "Users list found");
}
}
A handy way to use the APIResponse Wrapper
Now, Using about API Response wrapper, you can enforce to return wrapped response in your service layer. The advantages are;
- Consistent Response Structure: Utilizing a standardized wrapper ensures a consistent structure for API responses across different service methods.
- Error Handling Centralization: Centralizing error handling within the wrapper allows for a unified approach to managing errors and error messages.
- Enhanced Readability: The wrapper enhances the readability of the service layer code by clearly delineating success and failure paths in the responses.
@Log4j2
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository; public APIResponse<UserDto> findUserById(String userId) {
Optional<User> userOptional = userRepository.findById(userId);
if (userOptional.isEmpty()) {
log.error("User with id {} not found", userId);
return APIResponse.notFound(
null,
UserConstants.getUserResponseHashMap(),
UserConstants.USER_RESPONSE_CODE_PREFIX.concat("1"));
} User user = userOptional.get(); UserDto userDTO =
UserDto.builder()
.id(user.getId())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.build(); return APIResponse.ok(
userDTO,
UserConstants.getUserResponseHashMap(),
UserConstants.USER_RESPONSE_CODE_PREFIX.concat("6"));
} public APIResponse<List<UserDto>> findAllUsers() {
List<User> userList = userRepository.findAll(); List<UserDto> userDTOList =
userList.stream()
.map(
user ->
UserDto.builder()
.id(user.getId())
.firstName(user.getFirstName())
.lastName(user.getLastName())
.build())
.toList(); return APIResponse.ok(
userDTOList,
UserConstants.getUserResponseHashMap(),
UserConstants.USER_RESPONSE_CODE_PREFIX.concat("10"));
}// other CRUD Operations or any business logics here
}
Finally, Your rest controllers will be standardized and hold the default APIResponse
in its endpoint methods.
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService; @GetMapping("/{id}")
public ResponseEntity<APIResponse<UserDto>> findUserById(@PathVariable String id) {
APIResponse<UserDto> response = userService.findUserById(id);
return ResponseEntity.status(HttpStatus.valueOf(response.getHttpStatus())).body(response);
} @GetMapping
public ResponseEntity<APIResponse<List<UserDto>>> findAllUsers() {
APIResponse<List<UserDto>> response = userService.findAllUsers();
return ResponseEntity.status(HttpStatus.valueOf(response.getHttpStatus())).body(response);
} @PostMapping
public ResponseEntity<APIResponse<UserDto>> addUser(@RequestBody UserDto userDTO) {
APIResponse<UserDto> response = userService.addUser(userDTO);
return ResponseEntity.status(HttpStatus.valueOf(response.getHttpStatus())).body(response);
} @PutMapping
public ResponseEntity<APIResponse<UserDto>> updateUser(@RequestBody UserDto userDTO) {
APIResponse<UserDto> response = userService.updateUser(userDTO);
return ResponseEntity.status(HttpStatus.valueOf(response.getHttpStatus())).body(response);
}
// other api methods goes here
}
Handle Generic Exceptions
As a common practice, we typically throw exceptions specific to the customer module for any errors encountered in our workflows. Alternatively, we implement an Exception handler controller advice where we define all exceptions in a structured manner. However, from my personal experience, maintaining such controller advice to handle various exceptions related to different modules can be error-prone for developers.
A notable advantage of this approach is the ability to enforce the handling of all relevant errors in the service layer. These errors can then be encapsulated with a standardized API response, and any other unexpected exceptions can be handled generically.
Irrespective of the type of exception, every HTTP request made by clients will consistently generate an API response object in the response. This ensures standardized communication, even in the face of unexpected occurrences.
The client can identify unexpected occurrences simply by examining the internal code value, which, in this instance, will be GENERIC-ERROR.
You only require this:
@ControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<APIResponse<String>> handleCustomException(Exception ex) {
APIResponse<String> apiResponse =
APIResponse.<String>builder()
.httpStatus(HttpStatus.INTERNAL_SERVER_ERROR.value())
.status(UserConstants.FAILURE)
.message(ex.getMessage())
.internalCode(UserConstants.GENERIC_RESPONSE_CODE)
.payload(null)
.build(); return new ResponseEntity<>(apiResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Let’s See how the Swagger documentation sees this
You can see handy formats that you can practice across your teams.
Success Response:
Generic Error Response:
Disadvantages
When you consider the scope of the application and implementation effort, You may required to analyze the trade-off between the disadvantages of this approach. such as;
- Increased Boilerplate: Implementing the wrapper may introduce additional boilerplate code in each service method, potentially leading to increased code verbosity.
- Potential Overhead: In scenarios where simplicity and minimalism are critical, the wrapper might introduce an overhead by requiring additional considerations for each method’s response.
- Don’t make this mistake :D
Importance of long-run mult-module complex product with autonomous teams
In simpler terms, using a default APIResponse for each part of your application is a good idea. It keeps things consistent, makes it easier for others to understand, and helps handle both successful and error responses in a clear way. When you’re building an API, taking the time to set up a default APIResponse might seem like a small thing, but it really helps your application work well and be easy to use. As a developer, using good practices like these can make a big difference in the long run.
Now, let’s talk about why this is important for building a complex multi-module dependent product. When you’re working on a big project that has many parts depending on each other, it’s crucial to have a consistent way of handling responses. Even though using a default response might add a bit more code to each part, think of it as an investment. Yes, there might be a bit more to write, and in some cases, it might seem a little more complex, but in the long run, it pays off. Having a standardized way of dealing with responses makes the whole system easier to manage and expand. It’s like building a strong foundation for a house — it might take a bit more effort at the beginning, but it ensures that your structure stands tall and sturdy over time. So, even if there are some initial trade-offs, the benefits of using a default APIResponse practice outweigh them when it comes to maintaining and scaling a large and interconnected system.
Summary
In Summary, using a default APIResponse for each part of your application has lots of benefits. It keeps things consistent, makes it easier for clients to use, and gives a clear way to handle both successful and error responses. By following this method in your API development, you make your system easier to keep up and expand.
Even though setting up a default response might seem like a small thing, it’s these little details that help your application work well and be easy to understand. As developers, following these good practices can make a big difference in the long run.