Skip to main content
PProfileMatch

Spring Boot REST Controllers, DTOs and Validation

Backend DevelopmentLesson 2 of 46 min read

What you'll learn

  • Map HTTP verbs and paths with @RestController, @GetMapping and @PostMapping
  • Separate request and response DTOs from internal models using Java records
  • Validate input with @Valid and Jakarta Bean Validation annotations
  • Return consistent error bodies and correct status codes from one @RestControllerAdvice

On this page

  1. Controllers map HTTP to methods
  2. Why you need DTOs
  3. The controller and the @Valid trigger
  4. One place for every error
  5. Common mistakes and the symptoms they produce
  6. How this shows up in real projects and interviews

A clinic's booking desk sends this request: a patient name of empty string, an email that is not an email and a slot in 2019. Without validation, your service happily stores an appointment in the past for a nobody, and the front-desk screen shows a success message. Controllers are the border of your application, and this lesson is about making that border strict, readable and consistent.

Controllers map HTTP to methods

@RestController is @Controller plus @ResponseBody: every return value is serialised (by Jackson, by default) into the response body. @RequestMapping on the class gives a shared prefix, and @GetMapping, @PostMapping, @PutMapping and @DeleteMapping bind individual methods. Parameters come from the request through @PathVariable (a segment of the path), @RequestParam (a query-string value) and @RequestBody (the JSON payload). For the underlying HTTP ideas such as idempotency and status codes, see the REST API development course.

Why you need DTOs

A DTO (data transfer object) is a class that exists only to describe what crosses the wire. It is tempting to accept your database entity directly in the controller, but then a client can send fields you never meant to expose (an id, a status), and renaming a column silently changes your public API. Java records are an ideal DTO: immutable, concise and supported by Jackson in Boot 3. Request and response get separate types because they carry different fields.

BookingRequest.java and BookingResponse.java
package com.example.clinic;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;

public record BookingRequest(
        @NotBlank(message = "patient name is required")
        @Size(max = 80, message = "patient name must be at most 80 characters")
        String patientName,

        @NotBlank(message = "contact email is required")
        @Email(message = "contact must be a valid email address")
        String contact,

        @NotNull(message = "slot is required")
        @Future(message = "slot must be in the future")
        LocalDateTime slot) {
}

// In its own file: BookingResponse.java
// public record BookingResponse(long id, String patientName, LocalDateTime slot) {}

Validation annotations come from Jakarta Bean Validation, which Boot does not include in the web starter. Add spring-boot-starter-validation to your dependencies, or the annotations will compile but never run. That is the single most common cause of "my @NotBlank is ignored".

The controller and the @Valid trigger

Adding @Valid before @RequestBody tells Spring to run the constraints right after JSON binding and before your method body. A failure never reaches your code: Spring throws MethodArgumentNotValidException and the framework answers 400. Note the 201 and Location header on creation, which is the convention for a successful POST that creates a resource.

AppointmentController.java
package com.example.clinic;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

@RestController
@RequestMapping("/api/appointments")
public class AppointmentController {

    private final Map<Long, BookingResponse> store = new ConcurrentHashMap<>();
    private final AtomicLong ids = new AtomicLong();

    @PostMapping
    public ResponseEntity<BookingResponse> book(@Valid @RequestBody BookingRequest request) {
        long id = ids.incrementAndGet();
        BookingResponse saved = new BookingResponse(id, request.patientName(), request.slot());
        store.put(id, saved);

        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(id).toUri();
        return ResponseEntity.created(location).body(saved);
    }

    @GetMapping("/{id}")
    public BookingResponse find(@PathVariable long id) {
        BookingResponse found = store.get(id);
        if (found == null) {
            throw new AppointmentNotFoundException(id);
        }
        return found;
    }
}

A valid POST /api/appointments with {"patientName":"Asha Rao","contact":"asha@example.com","slot":"2031-03-04T09:30:00"} returns status 201 and the body {"id":1,"patientName":"Asha Rao","slot":"2031-03-04T09:30:00"}. Boot's default Jackson setup writes LocalDateTime as an ISO string, not a numeric timestamp array.

Two details are worth knowing before you grow this controller. First, mapping between DTOs and internal models is deliberately boring code: a static from(...) method on the response record is enough for small services, and a mapping library only pays off when you have dozens of such pairs. Second, query parameters deserve defaults. @RequestParam(defaultValue = "0") int page keeps GET /api/appointments working when the client sends nothing, while @RequestParam(required = false) String doctor turns a filter into an optional one. A required parameter that is absent produces a 400 with Required request parameter 'doctor' is not present, which is a perfectly good contract if you intend it.

One place for every error

Left alone, Boot returns its own generic error JSON, and every team member invents a different shape for custom failures. A @RestControllerAdvice class centralises the translation from exceptions to responses, so controllers just throw. Below, a missing appointment becomes 404 and a field-level validation problem becomes 400 with a map of field to message.

ApiExceptionHandler.java
package com.example.clinic;

import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ApiExceptionHandler {

    public record ApiError(String message, Map<String, String> fields) {}

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError onInvalidBody(MethodArgumentNotValidException ex) {
        Map<String, String> fields = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(fe -> fields.put(fe.getField(), fe.getDefaultMessage()));
        return new ApiError("Validation failed", fields);
    }

    @ExceptionHandler(AppointmentNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError onNotFound(AppointmentNotFoundException ex) {
        return new ApiError(ex.getMessage(), Map.of());
    }
}

// AppointmentNotFoundException.java
// public class AppointmentNotFoundException extends RuntimeException {
//     public AppointmentNotFoundException(long id) { super("Appointment " + id + " not found"); }
// }

Send the same request but with "patientName":"" and a valid email and slot. Only one constraint fails, so the 400 body is exactly {"message":"Validation failed","fields":{"patientName":"patient name is required"}}. With several bad fields, the entries are all present, but do not rely on their order.

SituationStatusHow it happens here
Created a resource201 CreatedResponseEntity.created(location)
Read an existing resource200 OKReturn the DTO
Body fails validation400 Bad Request@Valid throws, advice maps it
Resource does not exist404 Not FoundThrow a domain exception, advice maps it
Slot already booked409 ConflictAnother domain exception mapped to 409

Keep the rule about where checks live simple. Shape checks (is it blank, is it an email, is it in the future) belong on the DTO because they need no database. Business rules (is the slot already taken, is the doctor on leave) belong in the service, which throws a domain exception that the advice translates. Mixing the two leads to controllers that query the database just to decide whether to return 400, and error handling that nobody can find.

Common mistakes and the symptoms they produce

  • Forgetting the validation starter. Constraints are silently ignored and an empty name is saved. Add spring-boot-starter-validation.
  • Forgetting `@Valid`. Same symptom. The annotations on the record do nothing unless something triggers validation.
  • Returning the JPA entity. You expose internal fields, and with lazy relationships Jackson can throw LazyInitializationException or recurse forever on bidirectional links. Map to a DTO.
  • Unreadable JSON. A malformed body or a date such as "2031-3-4" raises HttpMessageNotReadableException, which Boot answers with 400 and a vague message. Handle it explicitly if clients need clearer text.
  • Wrong status on create. Returning 200 with a body for a POST that created something works, but clients and API tests expect 201 and a Location header.

How this shows up in real projects and interviews

Almost every Spring Boot take-home task is a small CRUD API, and reviewers look at three things: are inputs validated, are errors consistent, and are entities kept out of the API? Expect interview questions on @Valid versus @Validated, the difference between @RequestParam and @PathVariable, and why a global exception handler beats try/catch in every controller. For endpoint-design theory such as pagination and versioning, see the REST API course; for the status codes in practice, this lesson's table is a good cheat sheet.

Try it yourself

Add GET /api/appointments?doctor=... and a rule that two appointments cannot share the same slot. Throw a SlotTakenException from the booking method and map it to 409 with the same ApiError shape. Then POST the same slot twice with curl and confirm the first returns 201 and the second 409.

Show a hint

Keep slots in a Set<LocalDateTime> guarded by ConcurrentHashMap.newKeySet() and use add(...)'s boolean result to detect duplicates atomically, rather than checking contains first.

Key takeaways

  • @RestController plus mapping annotations connect HTTP requests to methods, and Jackson turns return values into JSON.
  • Use separate request and response DTOs (records work well) instead of exposing entities.
  • Bean Validation needs spring-boot-starter-validation and a @Valid on the @RequestBody parameter to run.
  • Centralise error handling in a @RestControllerAdvice so every failure has one predictable shape and the right status code.

Test yourself

A trick from this lesson's ride: @RestController = @Controller + @ResponseBody: the return value is the reply. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between @Valid and @Validated in Spring Boot?

@Valid is the standard Jakarta annotation that triggers validation of an object and cascades into nested objects. @Validated is Spring's variant that additionally supports validation groups and, on a class, enables method-level validation of parameters such as @RequestParam. For a @RequestBody DTO, @Valid is enough.

Why is @NotBlank not working in my Spring Boot application?

The usual causes are a missing spring-boot-starter-validation dependency, a missing @Valid on the request body parameter, or imports from the wrong package. In Boot 3 the annotations live in jakarta.validation.constraints, not javax.

Should I return the entity or a DTO from a Spring Boot controller?

Return a DTO. Entities mirror your tables and relationships, so returning them leaks internal fields, couples your API to your schema and can trigger lazy-loading errors during serialisation. A small record per response keeps the contract explicit.