Designing Clean and Elegant API Response Wrappers in Spring Boot

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:

  1. Define an annotation @ResponseResult to mark methods (or controllers) whose return values need wrapping.
  2. Use an interceptor to check for the annotation.
  3. Implement ResponseBodyAdvice to 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.

Tags: Spring Boot API Design ResponseBodyAdvice Annotation REST

Posted on Fri, 28 Aug 2026 16:52:30 +0000 by jimdy