This episode covers input safety and consistent error handling: Jakarta Bean Validation with @Valid and @NotNull, custom validation annotations, global exception handling with @ControllerAdvice and @ExceptionHandler, and a uniform error response format across the whole API.

An API that accepts input without validation is an entry point for all sorts of problems — corrupt data, unexpected exceptions, and even injection attacks. Episode 7 teaches two pillars of defense: input validation with Jakarta Bean Validation and centralized exception handling.
You'll produce an API that rejects bad data with clear messages and returns the same error format for every endpoint. This is what separates a hastily built API from a production-ready one.
Validation is provided by the spring-boot-starter-validation dependency, which wraps Hibernate Validator — the standard Jakarta Bean Validation implementation:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>This dependency must be added explicitly — the spring-boot-starter-web starter doesn't include it by default since Spring Boot 2.3.
Mark model fields with constraint annotations. Example on the ItemRequest request class:
public class ItemRequest {
@NotBlank(message = "Nama tidak boleh kosong")
@Size(max = 100, message = "Nama maksimal 100 karakter")
private String name;
@NotNull(message = "Harga wajib diisi")
@Positive(message = "Harga harus bernilai positif")
private BigDecimal price;
}Commonly used constraints: @NotBlank for required text, @NotNull for required objects, @Size for length limits, @Min and @Max for numbers, and @Email and @Pattern for specific formats. Provide specific messages so the client understands the problem.
The constraints only take effect after you add @Valid to the controller parameter:
@PostMapping
public ResponseEntity<Item> createItem(@Valid @RequestBody ItemRequest request) {
Item saved = itemService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}When the payload is invalid, Spring automatically throws MethodArgumentNotValidException and returns status 400 Bad Request along with the list of errors. Without @Valid, all constraints are ignored.
Sometimes the built-in constraints aren't enough. For special rules, create your own annotation. Example: making sure the price isn't lower than a configured minimum price:
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = HargaMinimumValidator.class)
public @interface HargaMinimum {
String message() default "Harga di bawah minimum";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}And the logic implementation:
public class HargaMinimumValidator
implements ConstraintValidator<HargaMinimum, BigDecimal> {
@Override
public boolean isValid(BigDecimal value,
ConstraintValidatorContext context) {
return value == null || value.compareTo(new BigDecimal("1000")) >= 0;
}
}This custom annotation can be used exactly like a built-in constraint: @HargaMinimum on a field. The isValid logic must handle null values properly so it doesn't conflict with @NotNull.
Instead of handling errors in every controller, centralize them in one class with @ControllerAdvice. This class processes exceptions from all controllers:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ItemNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(
ItemNotFoundException ex) {
ApiError error = new ApiError(HttpStatus.NOT_FOUND,
ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(
MethodArgumentNotValidException ex) {
List<String> messages = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.toList();
ApiError error = new ApiError(HttpStatus.BAD_REQUEST, messages);
return ResponseEntity.badRequest().body(error);
}
}With this pattern, the logic of converting exceptions into responses lives in one place. Controllers stay clean, and error handling is consistent across the whole application.
So clients can easily consume errors, use a single ApiError shape across all endpoints:
{
"status": 404,
"message": "Item dengan id 99 tidak ditemukan",
"timestamp": "2026-08-10T08:00:00Z"
}The status, message, and timestamp structure is used for all errors — from validation to business exceptions. Format consistency is the key so client-side error handling can be written once for every case.
Test the API's error behavior with an invalid request:
curl -X POST http://localhost:8080/api/items \
-H "Content-Type: application/json" \
-d '{"name":"","price":-5}'The curl -X POST http://localhost:8080/api/items -d '{"name":"","price":-5}' command should return 400 Bad Request with the list of problematic fields — the result of @Valid and the validation handler we built. A tidy JSON response means your error handling is working.
Episode 7 equipped you with two strong API defenses: input validation with Jakarta Bean Validation, custom validation annotations, and centralized global exception handling with @ControllerAdvice and @ExceptionHandler that produces a consistent error format.
Key takeaways:
spring-boot-starter-validation must be added to enable Bean Validation.@NotBlank and @Size are placed on the model and activated with @Valid.@Constraint and a dedicated validator class.@ControllerAdvice centralizes exception handling from all controllers.status, message, and timestamp.curl to make sure the status code and messages are correct.In the next episode, episode 8, we'll discuss application configuration and properties — the comparison between application.properties and application.yml, externalized configuration with environment variables, profile-specific configuration, and how to secure secrets and credentials. Your API will be ready to adapt to different environments.