1. Spring MVC & @RestController
Spring MVC is the web framework underneath every HTTP endpoint you'll write in this
course. @RestController is the annotation that turns a plain class into
one: it's shorthand for @Controller plus @ResponseBody,
meaning every method's return value is serialized straight into the HTTP response
body (as JSON, by default) instead of being resolved to a view template.
@RestController vs @Controller
Plain @Controller is for server-rendered HTML — a method returns a
view name that a template engine resolves. This course never renders HTML
server-side, so every controller you write from here on is @RestController.
Mapping HTTP methods
@GetMapping, @PostMapping, @PutMapping, and
@DeleteMapping map a method to a specific HTTP verb and path — they're
shorthand for @RequestMapping(method = ...). Class-level
@RequestMapping("/api/tasks") sets a shared prefix for every method
underneath it.
package com.codeverse.week03.controller;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/tasks")
class TaskController {
@GetMapping
String listAll() {
return "all tasks";
}
@GetMapping("/{id}")
String getOne(@PathVariable Long id) {
return "task " + id;
}
@PostMapping
String create() {
return "created";
}
@PutMapping("/{id}")
String update(@PathVariable Long id) {
return "updated " + id;
}
@DeleteMapping("/{id}")
void delete(@PathVariable Long id) {
// 204 No Content, no body
}
}
Reading the request: @PathVariable, @RequestParam, @RequestBody
@PathVariable pulls a value out of the URL path itself (like the
{id} above). @RequestParam reads a query string parameter
— optionally with a default, via defaultValue. @RequestBody
deserializes the entire JSON request body into a Java object using Jackson, which
Spring Boot's web starter configures for you automatically.
@GetMapping
List<TaskResponse> listAll(
@RequestParam(required = false, defaultValue = "false") boolean completedOnly) {
// completedOnly comes from ?completedOnly=true in the query string
...
}
@PostMapping
ResponseEntity<TaskResponse> create(@RequestBody CreateTaskRequest request) {
// request was deserialized from the JSON request body
...
}
Everything from Week 2 applies here — a real controller doesn't build its own service objects, it declares a final field and receives the service through a constructor. Spring wires it in automatically because the controller is itself a bean.
2. Request & Response DTOs
A DTO (Data Transfer Object) is a small, purpose-built type that represents exactly
what a client sends or receives over HTTP — nothing more. It is not
your domain entity, and starting Week 4 you'll have a real JPA-mapped Task
entity sitting behind these controllers. That entity should never appear in a
controller signature.
Why not just return the entity?
Returning an entity directly from a controller couples your public API contract to your database schema — rename a column and you silently break every client. It also risks over-exposure (internal fields, audit timestamps, or lazy-loaded associations serializing unpredictably) and under-exposure (fields the client needs to send that the entity doesn't cleanly model, like a confirmation flag). A dedicated DTO layer keeps "what the database looks like" and "what the API looks like" free to evolve independently.
Request vs response DTOs
It's normal — and usually correct — to have a different type for input than for
output, since a create request rarely includes fields like id or
createdAt that only exist after the server has processed it. Records are
a natural fit for both: they're immutable, and their generated
equals()/toString() make them trivial to assert against in
tests.
package com.codeverse.week03.dto;
public record CreateTaskRequest(String title, String description) {}
package com.codeverse.week03.dto;
import java.time.Instant;
public record TaskResponse(
Long id,
String title,
String description,
boolean completed,
Instant createdAt) {}
Mapping between DTO and domain object
For now — with an in-memory domain object rather than a JPA entity — mapping is a
plain static factory method. This same pattern carries forward once a real
Task entity exists in Week 4; only the source type changes.
package com.codeverse.week03.dto;
import com.codeverse.week03.model.Task;
public final class TaskMapper {
private TaskMapper() {}
public static TaskResponse toResponse(Task task) {
return new TaskResponse(
task.id(),
task.title(),
task.description(),
task.completed(),
task.createdAt());
}
}
3. Bean Validation
Bean Validation lets you declare constraints directly on a DTO's fields, then enforce
them with a single annotation on the controller parameter — no hand-written
if checks scattered through your method bodies. Spring Boot's web
starter pulls in jakarta.validation annotations; add
spring-boot-starter-validation to actually enforce them.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Common constraint annotations
@NotBlank rejects null, empty, and whitespace-only strings.
@Size(min=, max=) bounds a string's or collection's length.
@Email checks a string looks like an email address.
@Positive (and @PositiveOrZero, @Min,
@Max) constrain numeric fields.
package com.codeverse.week03.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateTaskRequest(
@NotBlank(message = "title is required")
@Size(max = 120, message = "title must be 120 characters or fewer")
String title,
@Size(max = 2000, message = "description must be 2000 characters or fewer")
String description
) {}
Triggering validation with @Valid
Constraints on the record do nothing by themselves. Adding @Valid to the
controller parameter tells Spring MVC to run the constraints against the
deserialized object before your method body ever executes:
@PostMapping
ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
...
}
Custom validation, briefly
For rules a built-in annotation can't express (uniqueness, cross-field checks), you
can define your own constraint annotation backed by a
ConstraintValidator<YourAnnotation, TargetType> implementation.
That's more machinery than a single-week API needs — the built-in annotations cover
nearly everything this course's exercises require, so treat custom validators as
something to reach for later rather than a Week 3 must-know.
A failed @Valid check throws MethodArgumentNotValidException. Without a handler for it, Spring's default behavior returns a 400 — but with a generic, unhelpful body. Section 5 fixes that with a consistent, field-level error shape.
4. ResponseEntity & Status Codes
A controller method can return a bare object — Spring serializes it and always sends
200 OK — or a ResponseEntity<T>, which wraps the body
together with an explicit status code and, optionally, headers. Once an endpoint has
more than one possible outcome (created vs. not found, updated vs. rejected),
ResponseEntity is the right tool.
When a bare return type is fine
If a method genuinely only ever succeeds with 200 OK — a simple lookup
that's guaranteed to find something, for instance — returning the DTO directly is
simpler and reads just as clearly. Reach for ResponseEntity the moment
you need to choose between outcomes.
Choosing the status code
200 OK for a successful read or update that returns a body.
201 Created for a successful POST that created a new
resource. 204 No Content for a successful operation with nothing to
return, like a delete. 400 Bad Request for malformed or failed-validation
input. 404 Not Found when the requested resource doesn't exist.
@GetMapping("/{id}")
ResponseEntity<TaskResponse> getOne(@PathVariable Long id) {
Task task = taskService.findById(id); // throws TaskNotFoundException -> 404, see section 5
return ResponseEntity.ok(TaskMapper.toResponse(task));
}
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable Long id) {
taskService.delete(id);
return ResponseEntity.noContent().build(); // 204
}
The Location header on creation
REST convention says a successful POST that creates a resource should
return 201 Created with a Location header pointing at the
new resource's URL, so the client knows where to fetch it without guessing.
ResponseEntity.created(uri) sets both the status and the header together.
import java.net.URI;
@PostMapping
ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
Task created = taskService.create(request);
URI location = URI.create("/api/tasks/" + created.id());
return ResponseEntity.created(location).body(TaskMapper.toResponse(created));
}
5. Centralized Error Handling with @ControllerAdvice
Sprinkling try/catch through every controller method to translate
exceptions into HTTP responses doesn't scale — the mapping logic gets duplicated, and
it's easy for one endpoint to drift out of sync with the rest.
@ControllerAdvice solves this by centralizing exception handling for
every controller in the application into one class.
@ExceptionHandler methods
Inside an @ControllerAdvice-annotated class, each method annotated
@ExceptionHandler(SomeException.class) intercepts that exception type
whenever it escapes any controller, and returns the response your controllers
would otherwise have had to build by hand.
A consistent error response shape
Every error a client receives — validation failure, missing resource, anything else — should come back in the same JSON shape, so client code has exactly one format to parse. A record works well here too:
package com.codeverse.week03.error;
import java.time.Instant;
import java.util.List;
public record ApiError(
Instant timestamp,
int status,
String error,
String message,
List<String> details) {
public static ApiError of(int status, String error, String message, List<String> details) {
return new ApiError(Instant.now(), status, error, message, details);
}
}
A custom domain exception
package com.codeverse.week03.error;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(Long id) {
super("No task found with id " + id);
}
}
The handler class
One handler maps TaskNotFoundException to 404; another maps
Spring's own MethodArgumentNotValidException — thrown when
@Valid fails — to 400, pulling the individual field errors
out into the details list.
package com.codeverse.week03.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.List;
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
ResponseEntity<ApiError> handleNotFound(TaskNotFoundException ex) {
ApiError body = ApiError.of(
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage(),
List.of());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
List<String> details = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.toList();
ApiError body = ApiError.of(
HttpStatus.BAD_REQUEST.value(),
"Bad Request",
"Validation failed for one or more fields",
details);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}
@RestControllerAdvice is to @ControllerAdvice what
@RestController is to @Controller — it adds
@ResponseBody so each handler's return value is serialized as JSON
directly, matching every other endpoint in the application.
This week's TaskService holds tasks in a plain in-memory List — there's no database yet. Week 4 swaps that for a real JpaRepository-backed store; the controller layer, DTOs, and this exact error-handling setup carry over essentially unchanged.
You'll come back to exactly these endpoints in Week 8's testing module, using MockMvc to assert on status codes, response bodies, and the ApiError shape without starting a real server — a good reason to keep the error format consistent now.
6. Hands-on Exercise
Build an in-memory Task REST API
No database yet — that's Week 4. Store tasks in a plain in-memory collection inside a service bean, and focus entirely on getting the controller layer, validation, status codes, and error handling right.
Requirements:
- Create a
Taskrecord (id,title,description,completed,createdAt) and aTaskServicebean that stores tasks in an in-memoryList<Task>orMap<Long, Task>, injected intoTaskControllervia constructor injection. - Implement full CRUD on
/api/tasks:GET /api/tasks(list all),GET /api/tasks/{id},POST /api/tasks,PUT /api/tasks/{id}, andDELETE /api/tasks/{id}. - Define a validated
CreateTaskRequestDTO with@NotBlankontitleand a@Sizelimit ondescription; apply it with@Valid @RequestBodyon the create (and update) endpoint(s). Never accept or return theTaskrecord directly — map to/from aTaskResponseDTO. - Return the correct status from every endpoint using
ResponseEntity:201 Createdwith aLocationheader onPOST,200 OKon successful reads/updates,204 No Contenton delete. - Create a
TaskNotFoundExceptionthrown byTaskServicewhen an id doesn't exist, and a@RestControllerAdviceclass that maps it to404and maps validation failures to400— both returned as the sameApiErrorJSON shape.
Keep TaskService thread-safety simple for now — a ConcurrentHashMap keyed by id plus an AtomicLong id generator is more than enough for an in-memory exercise, and it's a pattern you'll retire the moment Week 4 introduces a real repository.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why use a separate DTO instead of returning your Task entity/domain object directly from a controller?
Why use a separate DTO instead of returning your Task entity/domain object directly from a controller?
A DTO decouples your public API contract from your internal data model. If the entity is exposed directly, any change to it — a renamed field, a new lazy-loaded association, an internal audit column — silently changes or breaks the API for every client. A DTO also lets input and output shapes differ from the entity's shape entirely: a create request naturally omits server-generated fields like id and createdAt, and a response can omit internal-only fields the client has no business seeing. Once a real JPA entity exists from Week 4 onward, this separation also avoids serialization surprises from lazy-loaded relationships.
Q2
What does @Valid actually trigger, and what happens if a validation failure has no matching @ExceptionHandler?
What does @Valid actually trigger, and what happens if a validation failure has no matching @ExceptionHandler?
Adding @Valid to a @RequestBody parameter tells Spring MVC to run the Bean Validation constraints declared on that DTO's fields (@NotBlank, @Size, and so on) against the deserialized object before the controller method body executes. If any constraint fails, Spring throws MethodArgumentNotValidException instead of calling the method at all. Without a registered handler for that exception, Spring's default error resolution still returns a 400 Bad Request, but with its generic built-in error body rather than the application's own consistent ApiError shape — which is exactly the gap a @RestControllerAdvice handler closes.
Q3
Why does @ControllerAdvice centralize error handling better than a try/catch block in each controller method?
Why does @ControllerAdvice centralize error handling better than a try/catch block in each controller method?
A try/catch in every method duplicates the exception-to-HTTP-response mapping logic across the whole codebase, and that duplication inevitably drifts — one endpoint returns a slightly different error shape or status code than another because someone updated one catch block and forgot the rest. An @ExceptionHandler method inside an @ControllerAdvice (or @RestControllerAdvice) class intercepts a given exception type wherever it escapes from any controller in the application, so the mapping to a status code and an ApiError body is written exactly once and guaranteed to be applied consistently everywhere, including in controllers written later by someone who never has to think about it.
Q4
How do you decide between returning 200 OK, 201 Created, and 204 No Content?
How do you decide between returning 200 OK, 201 Created, and 204 No Content?
The choice follows what the operation did and whether it has a body to return. 200 OK fits a successful read or update that returns a representation of the resource in the body — a GET or a PUT that hands back the updated task. 201 Created is specifically for a successful POST that created a brand-new resource; convention pairs it with a Location header pointing at the new resource's URL, which ResponseEntity.created(uri) sets for you. 204 No Content signals success with nothing meaningful to return in the body, which is the normal choice for a DELETE — the resource is gone, so there's nothing left to represent.