Spring Boot by Building · Chapter 9

API boundaries: DTOs, validation, and error responses

TaskFlow works, but the HTTP boundary is still too trusting. This chapter separates database shape from request shape, rejects bad input before it reaches the service, and returns one consistent error body from the whole API.

Chapter 9 · You build with guardrails · Mission ~25 min · TaskFlow continues


Choose your route

Study the same API Boundaries: DTOs, Validation & Errors explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.

Learning goal

Shared core

The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.

Learn deeply: build evidence

Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.

Prepare for interviews: explain the trade-off

Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.

// why this matters

An entity is your persistence model. A DTO is your API contract. Letting clients send entities directly feels fast at first, but it lets them choose fields they should not own, like id, createdAt, owner, or future internal flags. The boundary is where you defend the shape of your program.

Section 1The Java bridge

OOP encapsulation

A DTO is a public method parameter for your API. It exposes only what callers are allowed to provide.

Exceptions

Services throw meaningful exceptions; one web advisor translates them into HTTP status codes.

Records

Request/response DTOs are often perfect Java records: immutable, small, and named by their fields.

Jackson

Spring uses Jackson to turn JSON into your DTO and your DTO into JSON. Validation runs after binding.


Section 2The target shape

By the end, TaskFlow should have this boundary:

JSON request→ TaskRequest DTO + @Valid→ TaskService→ Task entity→ TaskResponse DTO→ JSON response
pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
dto/TaskRequest.java
package com.example.taskflow.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record TaskRequest(@NotBlank @Size(max = 120) String title, boolean done
) { }
dto/TaskResponse.java
package com.example.taskflow.dto;

public record TaskResponse(Long id, String title, boolean done) { }
what changed
  • The client sends TaskRequest, not Task. No client-controlled id.
  • The API returns TaskResponse. If the entity grows internal fields later, they stay private.
  • @Valid in the controller turns annotations into real HTTP 400 behavior.

Section 3Mission: make TaskFlow reject bad task input

Mission 9.1 you build

Replace entity request bodies with DTOs

Goal. POST /tasks and PUT /tasks/{id} accept TaskRequest, validate the title, and return TaskResponse.

  • POST /tasks with {"title":"Read JPA","done":false} returns 201 or 200 with id/title/done.
  • POST /tasks with blank title returns 400, before the service saves anything.
  • The controller no longer accepts Task as a @RequestBody.
  • The service still owns creation/update rules; the controller only maps HTTP DTOs to service calls.

Likely files: pom.xml, dto/TaskRequest.java, dto/TaskResponse.java, TaskController.java, maybe TaskService.java.

Commit checkpoint: feat: add task API dto validation

Hint 1 — boundary placement
Put @Valid in the controller method parameter: create(@Valid @RequestBody TaskRequest request). The service should not know HTTP annotations.
Hint 2 — mapping shape
A private controller helper is fine: private TaskResponse toResponse(Task task). Avoid adding a mapper abstraction until mapping is painful.
Reference patch — open after attempting
TaskController.java shape
@PostMapping
public ResponseEntity<TaskResponse> create(@Valid @RequestBody TaskRequest request) {
    Task created = service.create(request.title(), request.done());
    return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(created));
}

@PutMapping("/{id}")
public TaskResponse update(@PathVariable Long id, @Valid @RequestBody TaskRequest request) {
    return toResponse(service.update(id, request.title(), request.done()));
}

private TaskResponse toResponse(Task task) {
    return new TaskResponse(task.getId(), task.getTitle(), task.isDone());
}

The important part is not the exact helper name. The important part is: request DTO in, service rule in the service, response DTO out.


Section 4Mission: one error body for the whole API

Mission 9.2 you build

Add @RestControllerAdvice

Goal. 404 and validation failures return the same error envelope: status, error, message, timestamp.

  • Missing task id returns 404 with JSON, not a stack trace or HTML page.
  • Blank title returns 400 with a field-specific message.
  • Controllers do not build error responses manually.

Likely files: web/ApiError.java, web/GlobalExceptionHandler.java.

Commit checkpoint: feat: centralize api error responses

Hint 1 — one exception per handler
Start with two methods: one for TaskNotFoundException, one for MethodArgumentNotValidException.
Hint 2 — validation message
ex.getBindingResult().getFieldErrors() contains the field and message generated by Bean Validation.
Reference patch — error handler
web/ApiError.java + web/GlobalExceptionHandler.java
public record ApiError(int status, String error, String message, Instant timestamp) { }

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(TaskNotFoundException.class)
    ResponseEntity<ApiError> notFound(TaskNotFoundException ex) {
        return build(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ApiError> invalid(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream().findFirst().map(err -> err.getField() + " " + err.getDefaultMessage()).orElse("Validation failed");
        return build(HttpStatus.BAD_REQUEST, message);
    }

    private ResponseEntity<ApiError> build(HttpStatus status, String message) {
        return ResponseEntity.status(status).body(new ApiError(status.value(), status.getReasonPhrase(), message, Instant.now()));
    }
}

Notebook Summary

Boundary rules

Recall prompts

// next

Chapter 10 — Relationships and transactions. TaskFlow grows from loose tasks into users, projects, task ownership, and service methods that must succeed or fail as one unit.

TaskFlow checkpoint

Current state: persisted entities reach HTTP directly. Outcome: validated request and response DTOs plus one error envelope. Prerequisite: JPA entities. Java bridge: records are concise immutable boundary types. Estimate: 35 minutes.

Mission: make invalid input observable and safe

Accept a request DTO, map it in the service flow, and return a consistent JSON error for a blank title or missing task.

  • curl with a blank title returns 400 JSON with a useful message.
  • A missing task returns the same-shaped 404 envelope.
  • Entities do not appear as request bodies.

Likely files: dto/TaskRequest.java, dto/TaskResponse.java, web/GlobalExceptionHandler.java. Suggested commit: feat: validate taskflow api boundary.

Hint 1
Put @Valid where JSON enters.
Hint 2
Map server-owned fields in the service, not from the request.
Hint 3
Centralize exception-to-response translation.

Before opening the solution: send one bad payload, one missing id, and compare their response bodies.

Reference patch — boundary and errors
Request DTO in, service rule inside, response DTO out; advice owns error formatting once.

Recall before continuing

  1. Why is an entity a poor public request contract?
  2. What does @Valid protect?
  3. Why centralize API errors?

The next workshop adds ownership and transactions, so this safe boundary can represent multi-entity work.