API Boundaries: DTOs, Validation & Errors
Separate entity shape from API shape with request/response DTOs, reject invalid input with Bean Validation, and centralize error responses with @RestControllerAdvice.
Assumes you know
After this you should be able to
- Separate API and persistence shapes
- Return useful validation errors
Spring Boot by Building · Chapter 9
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.
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.
- Separate API and persistence shapes
- Return useful validation errors
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.
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#
A DTO is a public method parameter for your API. It exposes only what callers are allowed to provide.
Services throw meaningful exceptions; one web advisor translates them into HTTP status codes.
Request/response DTOs are often perfect Java records: immutable, small, and named by their fields.
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:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>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
) { }package com.example.taskflow.dto;
public record TaskResponse(Long id, String title, boolean done) { }- The client sends
TaskRequest, notTask. No client-controlledid. - The API returns
TaskResponse. If the entity grows internal fields later, they stay private. @Validin the controller turns annotations into real HTTP 400 behavior.
Section 3Mission: make TaskFlow reject bad task input#
Goal. POST /tasks and PUT /tasks/{id} accept
TaskRequest, validate the title, and return TaskResponse.
POST /taskswith{"title":"Read JPA","done":false}returns 201 or 200 with id/title/done.POST /taskswith blank title returns 400, before the service saves anything.- The controller no longer accepts
Taskas 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
@Valid in the controller method parameter:
create(@Valid @RequestBody TaskRequest request). The service should not know HTTP annotations.Hint 2 — mapping shape
private TaskResponse toResponse(Task task). Avoid adding a mapper abstraction until mapping is painful.Reference patch — open after attempting
@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#
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
TaskNotFoundException, one for
MethodArgumentNotValidException.Hint 2 — validation message
ex.getBindingResult().getFieldErrors() contains the field and message generated
by Bean Validation.Reference patch — error handler
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#
- Entity = persistence shape. DTO = API shape.
- Request DTOs protect server-owned fields. Response DTOs hide internal fields.
@Validbelongs at the controller boundary; business rules still belong in the service.
Recall prompts#
- Why is accepting an entity as a request body risky?
- What does Bean Validation check before the service is called?
- Why is one
@RestControllerAdvicebetter than formatting errors in every controller?
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.
curlwith 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
@Valid where JSON enters.Hint 2
Hint 3
Before opening the solution: send one bad payload, one missing id, and compare their response bodies.
Reference patch — boundary and errors
Recall before continuing#
- Why is an entity a poor public request contract?
- What does
@Validprotect? - Why centralize API errors?
The next workshop adds ownership and transactions, so this safe boundary can represent multi-entity work.