Back to blog
Backend Systemsintermediate

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.

LearnixoApril 16, 20265 min read
Spring BootREST APIJavaSpring MVCValidationOpenAPIBackend
Share:𝕏

Controller Structure

A @RestController combines @Controller and @ResponseBody — every method returns data serialized directly to the response body (JSON by default).

JAVA
@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:

JAVA
// 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

JAVA
@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:

JAVA
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

JAVA
@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:

JAVA
@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:

JSON
{
  "content": [...],
  "page": {
    "size": 10,
    "number": 0,
    "totalElements": 47,
    "totalPages": 5
  }
}

OpenAPI / Swagger Documentation

Add springdoc-openapi-starter-webmvc-ui dependency:

XML
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.7.0</version>
</dependency>

Annotate your API:

JAVA
@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

JAVA
@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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.