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.
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.
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.
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
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.
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.
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) { }
TaskRequest, not Task. No client-controlled id.TaskResponse. If the entity grows internal fields later, they stay private.@Valid in the controller turns annotations into real HTTP 400 behavior.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.Task as a @RequestBody.Likely files: pom.xml, dto/TaskRequest.java,
dto/TaskResponse.java, TaskController.java, maybe TaskService.java.
Commit checkpoint: feat: add task API dto validation
@Valid in the controller method parameter:
create(@Valid @RequestBody TaskRequest request). The service should not know HTTP annotations.private TaskResponse toResponse(Task task). Avoid adding a mapper abstraction until mapping is painful.@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.
@RestControllerAdviceGoal. 404 and validation failures return the same error envelope:
status, error, message, timestamp.
Likely files: web/ApiError.java, web/GlobalExceptionHandler.java.
Commit checkpoint: feat: centralize api error responses
TaskNotFoundException, one for
MethodArgumentNotValidException.ex.getBindingResult().getFieldErrors() contains the field and message generated
by Bean Validation.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()));
}
}
@Valid belongs at the controller boundary; business rules still belong in the service.@RestControllerAdvice better 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.
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.
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.Likely files: dto/TaskRequest.java, dto/TaskResponse.java, web/GlobalExceptionHandler.java. Suggested commit: feat: validate taskflow api boundary.
@Valid where JSON enters.Before opening the solution: send one bad payload, one missing id, and compare their response bodies.
@Valid protect?The next workshop adds ownership and transactions, so this safe boundary can represent multi-entity work.