In modern distributed systems and microservice architectures, the separation of frontend and backend is common. The overall system architecture is typically structured as follows:
Note: This article focuses on API interface design. Other components like gateways, caches, and message queues are omitted for clarity.
Interface Interaction
The frontend sends requests to the backend using predefined URL paths with parameters. The backend processes the requests and returns data.
RESTful URL conventions and common request headers (e.g.,
app_version,api_version,device) are beyond the scope of this article.
We'll concentrate on how the backend returns data to the frontend.
Response Format
Backend responses are generally in JSON format:
{
"code": integer,
"message": string,
"data": object
}
Status Codes
Ad-hoc status codes (e.g., 101 for permission error, 102 for paramter error) can become chaotic. A better approach is to follow HTTP status code conventions and group errors into ranges:
- 200 – success
- 301 – permanent redirect
- 404 – resource not found
- 500 – internal server error
Custom status code ranges:
- 1000–1999: parameter errors
- 2000–2999: user errors
- 3000–3999: interface exceptions
This categorization helps frontend developers quickly identify error types and locate issues with the accompanying message.
Message
The message field provides a friendly description of the error. It should be designed together with the status code, ideally using an enumeration.
Data
The data field is a JSON object whose structure varies by business logic.
Result Wrapper Class
Create a generic Result class:
public class Result<T> {
private int code;
private String message;
private T data;
public Result(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
}
Controller Example (Before Optimization)
@RestController
public class OrderController {
@GetMapping("/order/{id}")
public Result<Order> getOrder(@PathVariable Long id) {
Order order = orderService.getOrder(id);
return new Result<>(200, "success", order);
}
}
This works, but constructing Result objects everywhere is verbose. Let's optimize.
Improving with Static Methods
Add static factory methods to Result:
public class Result<T> {
// ... fields and constructors
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}
public static <T> Result<T> failure(int code, String message) {
return new Result<>(code, message, null);
}
}
Refactor the controller:
@GetMapping("/order/{id}")
public Result<Order> getOrder(@PathVariable Long id) {
if (id == null) {
return Result.failure(1001, "id must not be null");
}
Order order = orderService.getOrder(id);
return Result.success(order);
}
This is cleaner, but still forces every method to return Result, and adds boilerplate validation.
A More Elegant Approach
Instead of manually wrapping each response, we can:
- Define an annotation
@ResponseResultto mark methods (or controllers) whose return values need wrapping. - Use an interceptor to check for the annotation.
- Implement
ResponseBodyAdviceto automatically wrap the return value.
Annotation
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ResponseResult {
}
Interceptor (to flag requests)
public class ResponseResultInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
ResponseResult annotation = handlerMethod.getMethod().getAnnotation(ResponseResult.class);
if (annotation == null) {
annotation = handlerMethod.getBeanType().getAnnotation(ResponseResult.class);
}
if (annotation != null) {
request.setAttribute("RESPONSE_RESULT", true);
}
}
return true;
}
}
ResponseBodyAdvice Implementation
@ControllerAdvice
public class ResponseResultHandler implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
Boolean flag = (Boolean) request.getAttribute("RESPONSE_RESULT");
return flag != null && flag;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof Result) {
return body;
}
return Result.success(body);
}
}
Now the controller can return the actual business object directly:
@RestController
@ResponseResult
public class OrderController {
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.getOrder(id);
}
}
The response is automatically wrapped. For exceptions, the advice can also check if the body is an error and wrap it accordingly.
Summary
This approach uses an annotation and ResponseBodyAdvice to transparently wrap API responses, keeping controller methods clean. Further optimizations include caching the annotation check to avoid reflection overhead on every request. The basic idea can be extended to handle errors and other scenarios gracefully.