Elegant REST Controller Design in Spring Boot

Receiving Request Parameters

REST endpoints primarily handle GET and POST requests. The @RestController annotation combines @Controller and @ResponseBody, indicating that the class handles HTTP requests and automatically serializes return values to the response body. The @RequestMapping annotation sets the base path for all endpoints within the controller.

@RestController
@RequestMapping("/api/items")
public class ItemController {

    @Autowired
    private ItemService itemService;

    @GetMapping("/{id}")
    public ItemView fetchById(@PathVariable Integer id) {
        // ...
    }

    @PostMapping("/search")
    public Page<ItemView> search(Pageable pageable, ItemFilter filter) {
        // ...
    }
}

For POST requests, JSON payloads are automatically mapped to the corresponding object fields, such as an ItemFilter instance.

Standardized Status Codes

Response Format

Wrapping API responses in a consistent structure containing a status code, message, and payload allows clients to handle outcomes uniformly. Instead of returning raw data, responses should follow a standard format.

Raw data:

{
  "itemId": 1,
  "name": "Herbal Footbath",
  "price": 100.00
}

Wrapped data:

{
  "code": 2000,
  "message": "Successful operation",
  "data": {
    "itemId": 1,
    "name": "Herbal Footbath",
    "price": 100.00
  }
}

Wrapping with ApiResponse

Defining status codes using enums ensures type safety and maintainability. First, create a contract for all status codes:

public interface StatusContract {
    int getStatus();
    String getDescription();
}

Then, implement the enum:

@Getter
public enum ResponseStatus implements StatusContract {
    OK(2000, "Successful operation"),
    FAIL(2001, "Operation failed"),
    INVALID_INPUT(2002, "Invalid parameters"),
    WRAP_ERROR(2003, "Response wrapping failed");

    private final int status;
    private final String description;

    ResponseStatus(int status, String description) {
        this.status = status;
        this.description = description;
    }
}

Create the ApiResponse wrapper class with multiple constructors for flexibility:

@Data
public class ApiResponse<T> {
    private int code;
    private String message;
    private T data;

    public ApiResponse(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public ApiResponse(T data) {
        this.code = ResponseStatus.OK.getStatus();
        this.message = ResponseStatus.OK.getDescription();
        this.data = data;
    }

    public ApiResponse(StatusContract statusContract, T data) {
        this.code = statusContract.getStatus();
        this.message = statusContract.getDescription();
        this.data = data;
    }

    public ApiResponse(StatusContract statusContract) {
        this.code = statusContract.getStatus();
        this.message = statusContract.getDescription();
        this.data = null;
    }
}

Usage in a controller:

@PostMapping("/create")
public ApiResponse<ItemView> createItem(@Validated @RequestBody ItemRequest request) {
    Item item = new Item();
    BeanUtils.copyProperties(request, item);
    return new ApiResponse<>(itemService.save(item));
}

Centralized Validation

Manual Validation

Without a validation framework, controllers become cluttered with conditional checks:

@PostMapping("/create")
public ApiResponse<ItemView> createItem(ItemRequest request) {
    if (StringUtils.isBlank(request.getName())) {
        throw new CustomException("Item name cannot be empty");
    }
    if (request.getPrice() != null && request.getPrice().compareTo(BigDecimal.ZERO) < 0) {
        throw new CustomException("Price cannot be negative");
    }
    // ...
}

Declarative Validation with @Validated

Spring Validation eliminates boilerplate code. Add constraint annotations directly to the DTO:

@Data
public class ItemRequest {
    @NotBlank(message = "Item name is mandatory")
    private String name;

    @DecimalMin(value = "0.0", message = "Price cannot be negative")
    private BigDecimal price;

    private Boolean available;
}

Apply @Validated to the controller method parameter. If validation fails, Spring throws a BindException or MethodArgumentNotValidException.

Handling Validation Errors

Global exception handling using @RestControllerAdvice and @ExceptionHandler intercepts these exceptions and formats them into the standard ApiResponse:

@RestControllerAdvice
public class GlobalControllerAdvice {

    @ExceptionHandler(BindException.class)
    public ApiResponse<Void> handleBindException(BindException ex) {
        FieldError fieldError = ex.getBindingResult().getFieldError();
        String errorMessage = fieldError != null ? fieldError.getDefaultMessage() : "Validation error";
        return new ApiResponse<>(ResponseStatus.INVALID_INPUT, errorMessage);
    }
}

Clients will receive a cleanly formatted error response:

{
  "code": 2002,
  "message": "Invalid parameters",
  "data": "Price cannot be negative"
}

Unified Response Wrapping

Automatic Wrapping

Requiring developers to manually wrap every return value with new ApiResponse<>(data) is repetitive. Spring's ResponseBodyAdvice allows automatic wrapping of all controller responses.

@RestControllerAdvice(basePackages = "com.example.app")
public class ResponseWrapperAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return !returnType.getParameterType().isAssignableFrom(ApiResponse.class) 
               && !returnType.hasMethodAnnotation(SkipResponseWrap.class);
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  ServerHttpRequest request, ServerHttpResponse response) {
        if (body instanceof String) {
            ObjectMapper mapper = new ObjectMapper();
            try {
                return mapper.writeValueAsString(new ApiResponse<>(body));
            } catch (JsonProcessingException e) {
                throw new CustomException(ResponseStatus.WRAP_ERROR, e.getMessage());
            }
        }
        return new ApiResponse<>(body);
    }
}

The supports method filters out responses that are already wrapped or annotated to skip wrapping. The beforeBodyWrite method handles the actual wrapping, with special handling for String return types which require explicit JSON serialization. Controllers can now return raw objects:

@PostMapping("/add")
public ItemView addItem(@Validated @RequestBody ItemRequest request) {
    // ...
    return itemService.save(entity);
}

Excluding Specific Endpoints

Some endpoints, like health checks, must return raw strings without wrapping. Define a custom annotation to bypass the global wrapper:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SkipResponseWrap {}

@RestController
public class HealthCheckController {
    @GetMapping("/ping")
    @SkipResponseWrap
    public String ping() {
        return "pong";
    }
}

Global Exception Handling

Business logic often requires throwing custom exceptions. Define a dedicated enum for business error codes:

@Getter
public enum ServiceError implements StatusContract {
    BUSINESS_ERROR(3000, "Business rule violation"),
    STOCK_ERROR(3001, "Insufficient stock");

    private final int status;
    private final String description;

    ServiceError(int status, String description) {
        this.status = status;
        this.description = description;
    }
}

Create a custom exception class holding the error code, general message, and specific detail:

@Getter
public class CustomException extends RuntimeException {
    private final int code;
    private final String msg;

    public CustomException(StatusContract statusContract, String detail) {
        super(detail);
        this.code = statusContract.getStatus();
        this.msg = statusContract.getDescription();
    }

    public CustomException(String detail) {
        super(detail);
        this.code = ServiceError.BUSINESS_ERROR.getStatus();
        this.msg = ServiceError.BUSINESS_ERROR.getDescription();
    }
}

Extend the global exception handler to catch this custom exception:

@RestControllerAdvice
public class GlobalControllerAdvice {

    // ... existing handlers

    @ExceptionHandler(CustomException.class)
    public ApiResponse<String> handleCustomException(CustomException ex) {
        return new ApiResponse<>(ex.getCode(), ex.getMsg(), ex.getMessage());
    }
}

Throwing the exception anywhere in the codebase will now result in a standardized error response:

if (inventory <= 0) {
    throw new CustomException(ServiceError.STOCK_ERROR, "Item ID " + itemId + " is out of stock");
}

Response:

{
  "code": 3001,
  "message": "Insufficient stock",
  "data": "Item ID 99 is out of stock"
}

Tags: Spring Boot REST API Controller Exception Handling Response Wrapper

Posted on Sat, 09 May 2026 10:29:54 +0000 by kalebaustin