Spring Boot API Mastery: Validation, Global Exceptions, Responses and Security
This comprehensive guide walks you through building robust Spring Boot backend APIs, covering environment setup, parameter validation with Validator and groups, custom validation, global exception handling, unified response structures, optional response wrapping, API version control via path and header, and essential security measures such as token authentication, timestamp checks, URL signing, replay protection, and HTTPS usage.
Introduction
A backend API typically consists of four parts: URL, request method, request data, and response data. While there is no universal standard, consistency and proper validation are essential.
Environment Setup
Include the necessary dependencies in your project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>2.0.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>Parameter Validation
1. Introduction
Three common validation approaches are used; the tutorial focuses on the most concise third method.
Business‑layer validation
Validator + BindingResult validation
Validator + automatic exception throwing
2. Validator + BindingResult
Example controller method:
@PostMapping("/addUser")
public String addUser(@RequestBody @Validated User user, BindingResult bindingResult) {
List<ObjectError> allErrors = bindingResult.getAllErrors();
if (!allErrors.isEmpty()) {
return allErrors.stream()
.map(o -> o.getDefaultMessage())
.collect(Collectors.toList()).toString();
}
return validationService.addUser(user);
}3. Validator + Automatic Exception
Annotate the request object with validation constraints and add @Valid on the parameter. Spring will automatically throw MethodArgumentNotValidException when validation fails.
@Data
public class User {
@NotNull(message = "User ID cannot be null")
private Long id;
@NotNull(message = "Account cannot be null")
@Size(min = 6, max = 11, message = "Account length must be 6‑11 characters")
private String account;
@NotNull(message = "Password cannot be null")
@Size(min = 6, max = 16, message = "Password length must be 6‑16 characters")
private String password;
@NotNull(message = "Email cannot be null")
@Email(message = "Invalid email format")
private String email;
}Controller method:
@RestController
@RequestMapping("user")
public class ValidationController {
@Autowired
private ValidationService validationService;
@PostMapping("/addUser")
public String addUser(@RequestBody @Validated User user) {
return validationService.addUser(user);
}
}4. Group Validation and Recursive Validation
Define a group interface, apply it to constraint annotations, and specify the group in @Validated on the controller method.
public interface Update extends Default {}
@Data
public class User {
@NotNull(message = "User ID cannot be null", groups = Update.class)
private Long id;
// ... other fields
} @PostMapping("/update")
public String update(@Validated({Update.class}) User user) {
return "success";
}For nested objects, simply add @Valid on the field.
5. Custom Validation
Create a custom annotation and its validator:
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = HaveNoBlankValidator.class)
public @interface HaveNoBlank {
String message() default "String cannot contain spaces";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class HaveNoBlankValidator implements ConstraintValidator<HaveNoBlank, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true;
return !value.contains(" ");
}
}Global Exception Handling
1. Basic Usage
Create a class annotated with @RestControllerAdvice (or @ControllerAdvice) and define methods with @ExceptionHandler for specific exceptions.
@RestControllerAdvice
public class ExceptionControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public String handleValidation(MethodArgumentNotValidException e) {
ObjectError error = e.getBindingResult().getAllErrors().get(0);
return error.getDefaultMessage();
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResultVO<?> handleUnexpected(Exception ex) {
log.error("System error:", ex);
return new ResultVO<>(GlobalMsgEnum.ERROR);
}
}2. Custom Exception
Define a custom runtime exception carrying a code and message:
@Getter
public class APIException extends RuntimeException {
private int code;
private String msg;
public APIException() { this(1001, "Interface error"); }
public APIException(String msg) { this(1001, msg); }
public APIException(int code, String msg) {
super(msg);
this.code = code;
this.msg = msg;
}
}Handle it in the advice:
@ExceptionHandler(APIException.class)
public String handleAPI(APIException e) {
return e.getMsg();
}Unified Response Structure
Define an enum for result codes and a generic response wrapper.
@Getter
public enum ResultCode {
SUCCESS(1000, "Operation successful"),
FAILED(1001, "Response failed"),
VALIDATE_FAILED(1002, "Parameter validation failed"),
ERROR(5000, "Unknown error");
private int code;
private String msg;
ResultCode(int code, String msg) { this.code = code; this.msg = msg; }
} @Getter
public class ResultVO<T> {
private int code;
private String msg;
private T data;
public ResultVO(T data) { this(ResultCode.SUCCESS, data); }
public ResultVO(ResultCode rc, T data) {
this.code = rc.getCode();
this.msg = rc.getMsg();
this.data = data;
}
}Controllers can now return ResultVO objects, ensuring a consistent response format.
Optional Global Response Wrapping
Create a marker annotation to skip wrapping:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface NotResponseBody {}Implement ResponseBodyAdvice to wrap responses unless the method is annotated with NotResponseBody or already returns ResultVO:
@RestControllerAdvice(basePackages = {"com.csdn.demo1.controller"})
public class ResponseControllerAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return !(returnType.getParameterType().equals(ResultVO.class) ||
returnType.hasMethodAnnotation(NotResponseBody.class));
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (returnType.getGenericParameterType().equals(String.class)) {
try {
return new ObjectMapper().writeValueAsString(new ResultVO<>(body));
} catch (JsonProcessingException e) {
throw new APIException("String response error");
}
}
return new ResultVO<>(body);
}
}API Version Control
Path‑Based Versioning
Define an annotation and a custom request condition:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
String value() default "1.0";
}
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private static final Pattern VERSION_PREFIX_PATTERN = Pattern.compile("v(\\d+\\.\\d+)");
private final String version;
public ApiVersionCondition(String version) { this.version = version; }
@Override
public ApiVersionCondition combine(ApiVersionCondition other) { return new ApiVersionCondition(other.version); }
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
Matcher m = VERSION_PREFIX_PATTERN.matcher(request.getRequestURI());
if (m.find() && Objects.equals(m.group(1), version)) {
return this;
}
return null;
}
@Override
public int compareTo(ApiVersionCondition other, HttpServletRequest request) { return 0; }
public String getApiVersion() { return version; }
}Register a custom RequestMappingHandlerMapping that reads the annotation:
public class PathVersionHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(Class<?> beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
ApiVersion av = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
return av == null ? null : new ApiVersionCondition(av.value());
}
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
ApiVersion av = AnnotationUtils.findAnnotation(method, ApiVersion.class);
return av == null ? null : new ApiVersionCondition(av.value());
}
}Enable it via WebMvcRegistrations:
@Configuration
public class WebMvcConfiguration implements WebMvcRegistrations {
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new PathVersionHandlerMapping();
}
}Controller example:
@RestController
@ApiVersion
@RequestMapping("/{version}/test")
public class TestController {
@GetMapping("one")
public String query() { return "test api default"; }
@GetMapping("one")
@ApiVersion("1.1")
public String queryV11() { return "test api v1.1"; }
@GetMapping("one")
@ApiVersion("3.1")
public String queryV31() { return "test api v3.1"; }
}Header‑Based Versioning
Use a similar condition that reads a custom header (e.g., X-VERSION) instead of the URL path.
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private static final String X_VERSION = "X-VERSION";
private final String version;
// combine, compareTo same as before
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
String header = request.getHeader(X_VERSION);
return Objects.equals(version, header) ? this : null;
}
}API Security Measures
Token Authentication
After a successful login, issue a token (e.g., md5(userId + timestamp + serverSecret)) and store it in Redis. Subsequent requests must include the token, which is validated against the cache.
Timestamp Validation
Clients send a timestamp parameter; the server rejects requests where the timestamp differs from the current time by more than a configured threshold (e.g., 1 minute), mitigating replay and DoS attacks.
URL Signing
Sort all request parameters (including the URL) alphabetically, concatenate them with &, prepend or append a secret key, and compute an MD5 hash. The resulting sign is sent with the request and verified server‑side.
Replay Protection
Store each generated sign in Redis with the same expiration as the timestamp. If a request arrives with a previously seen sign, reject it as a replay.
HTTPS
All communication should be over HTTPS to encrypt data in transit and prevent eavesdropping.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architect's Guide
Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
