REST APIs with Spring MVC: Controllers, DTOs & Validation
Build production REST APIs with Spring MVC — @RestController, request mapping, DTOs with records, Bean Validation, global exception handling, pagination, and OpenAPI documentation.
Controller Structure
A @RestController combines @Controller and @ResponseBody — every method returns data serialized directly to the response body (JSON by default).
@RestController
@RequestMapping("/api/v1/appointments")
@RequiredArgsConstructor // Lombok: generates constructor for final fields
public class AppointmentController {
private final AppointmentService service;
@GetMapping
public ResponseEntity<Page<AppointmentResponse>> list(
@RequestParam(required = false) String clinicId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
Page<AppointmentResponse> result = service.listAppointments(clinicId, page, size);
return ResponseEntity.ok(result);
}
@GetMapping("/{id}")
public ResponseEntity<AppointmentResponse> getById(@PathVariable String id) {
return ResponseEntity.ok(service.getById(id));
}
@PostMapping
public ResponseEntity<AppointmentResponse> create(
@Valid @RequestBody AppointmentRequest request,
Authentication auth
) {
String userId = auth.getName();
AppointmentResponse created = service.create(request, userId);
URI location = URI.create("/api/v1/appointments/" + created.id());
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}/status")
public ResponseEntity<AppointmentResponse> updateStatus(
@PathVariable String id,
@Valid @RequestBody UpdateStatusRequest request
) {
return ResponseEntity.ok(service.updateStatus(id, request.status()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}DTOs with Records
Use records for request/response DTOs — they're immutable, concise, and serialise cleanly:
// Request DTO
public record AppointmentRequest(
@NotBlank(message = "clinicId is required")
String clinicId,
@NotBlank(message = "patientId is required")
String patientId,
@NotNull(message = "dateTime is required")
@Future(message = "Appointment must be in the future")
LocalDateTime dateTime,
@NotBlank
@Size(max = 200)
String notes,
@NotNull
AppointmentType type
) {}
// Response DTO — what the client receives
public record AppointmentResponse(
String id,
String clinicId,
String clinicName,
String patientId,
String patientName,
LocalDateTime dateTime,
AppointmentStatus status,
String notes,
AppointmentType type,
Instant createdAt
) {}
// Update DTO
public record UpdateStatusRequest(
@NotNull(message = "status is required")
AppointmentStatus status,
@Size(max = 500)
String reason
) {}Mapper — Entity to DTO
@Component
public class AppointmentMapper {
public AppointmentResponse toResponse(Appointment appt) {
return new AppointmentResponse(
appt.getId(),
appt.getClinic().getId(),
appt.getClinic().getName(),
appt.getPatient().getId(),
appt.getPatient().getFullName(),
appt.getDateTime(),
appt.getStatus(),
appt.getNotes(),
appt.getType(),
appt.getCreatedAt()
);
}
public Appointment toEntity(AppointmentRequest request, Clinic clinic, Patient patient) {
Appointment appt = new Appointment();
appt.setClinic(clinic);
appt.setPatient(patient);
appt.setDateTime(request.dateTime());
appt.setNotes(request.notes());
appt.setType(request.type());
appt.setStatus(AppointmentStatus.SCHEDULED);
return appt;
}
}Bean Validation
Spring Boot integrates Hibernate Validator. Annotate fields, trigger with @Valid on the controller parameter:
public record PatientRequest(
@NotBlank
@Size(min = 2, max = 50)
String firstName,
@NotBlank
@Size(min = 2, max = 50)
String lastName,
@NotBlank
@Email(message = "Must be a valid email")
String email,
@Pattern(regexp = "\\d{10}", message = "Phone must be 10 digits")
String phone,
@NotNull
@Past(message = "Date of birth must be in the past")
LocalDate dateOfBirth,
@NotNull
@Valid // validates nested object
InsuranceRequest insurance
) {}
// Custom validator
@Documented
@Constraint(validatedBy = UniqueEmailValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueEmail {
String message() default "Email already registered";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final PatientRepository repository;
public UniqueEmailValidator(PatientRepository repository) {
this.repository = repository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext ctx) {
return email == null || !repository.existsByEmail(email);
}
}Global Exception Handling
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Validation errors → 400 with field-level detail
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();
return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION_FAILED", errors));
}
// 404 — resource not found
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getMessage(), List.of()));
}
// 409 — business rule conflict
@ExceptionHandler(AppointmentConflictException.class)
public ResponseEntity<ErrorResponse> handleConflict(AppointmentConflictException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(ex.getMessage(), List.of()));
}
// 403 — access denied
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ErrorResponse("Access denied", List.of()));
}
// 500 — catch-all
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.internalServerError()
.body(new ErrorResponse("An unexpected error occurred", List.of()));
}
public record ErrorResponse(String message, List<FieldError> errors) {}
public record FieldError(String field, String message) {}
}Pagination & Sorting
Spring Data's Pageable integrates directly with controllers:
@GetMapping
public ResponseEntity<Page<AppointmentResponse>> list(
@RequestParam(required = false) String clinicId,
@PageableDefault(size = 20, sort = "dateTime", direction = Sort.Direction.ASC)
Pageable pageable
) {
return ResponseEntity.ok(service.list(clinicId, pageable));
}Client usage: GET /api/v1/appointments?page=0&size=10&sort=dateTime,asc
Response shape:
{
"content": [...],
"page": {
"size": 10,
"number": 0,
"totalElements": 47,
"totalPages": 5
}
}OpenAPI / Swagger Documentation
Add springdoc-openapi-starter-webmvc-ui dependency:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>Annotate your API:
@RestController
@RequestMapping("/api/v1/appointments")
@Tag(name = "Appointments", description = "Manage clinic appointments")
public class AppointmentController {
@Operation(summary = "List appointments", description = "Returns paginated appointments for a clinic")
@ApiResponse(responseCode = "200", description = "Appointments retrieved")
@ApiResponse(responseCode = "401", description = "Not authenticated")
@GetMapping
public ResponseEntity<Page<AppointmentResponse>> list(...) { ... }
@Operation(summary = "Create appointment")
@ApiResponse(responseCode = "201", description = "Appointment created",
content = @Content(schema = @Schema(implementation = AppointmentResponse.class)))
@ApiResponse(responseCode = "400", description = "Validation failed")
@PostMapping
public ResponseEntity<AppointmentResponse> create(...) { ... }
}Docs at: http://localhost:8080/swagger-ui.html
CORS Configuration
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins}")
private String[] allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}HTTP Status Code Conventions
| Scenario | Status | Response |
|----------|--------|----------|
| Successful read | 200 OK | Body with data |
| Resource created | 201 Created | Body + Location header |
| Updated, no body | 204 No Content | Empty |
| Validation failed | 400 Bad Request | Error body with field details |
| Not authenticated | 401 Unauthorized | Error body |
| No permission | 403 Forbidden | Error body |
| Not found | 404 Not Found | Error body |
| Conflict | 409 Conflict | Error body |
| Server error | 500 Internal Server Error | Generic error (no internals) |
REST API Knowledge Check
5 questions · Test what you just learned · Instant explanations
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.