Week 3: Building REST APIs

Week 2 covered how beans get created and wired together. This week puts that machinery to work on a real, well-validated HTTP API layer — proper @RestController classes, request and response DTOs instead of leaking your domain model, Bean Validation on every incoming request, the right HTTP status code for every outcome, and centralized error handling instead of a try/catch block copy-pasted into every method.

Module 3 of 12 Week 3 of 15 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Design clean REST endpoints with @RestController and request/response DTOs
  • Validate incoming requests with Bean Validation and @Valid
  • Centralize error handling with @ControllerAdvice and return correct HTTP status codes

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.

TaskController.java — skeleton
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.

reading input
@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
    ...
}
Controllers get dependencies injected too

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.

CreateTaskRequest.java
package com.codeverse.week03.dto;

public record CreateTaskRequest(String title, String description) {}
TaskResponse.java
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.

TaskMapper.java
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.

pom.xml — add this starter
<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.

CreateTaskRequest.java — validated
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:

TaskController.java
@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.

Where this goes wrong without a handler

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.

TaskController.java — status codes in practice
@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.

TaskController.java — POST /api/tasks
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:

ApiError.java
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

TaskNotFoundException.java
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.

GlobalExceptionHandler.java
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.

Looking ahead to Week 4

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.

Looking ahead to Week 8

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

Hands-on

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:

  1. Create a Task record (id, title, description, completed, createdAt) and a TaskService bean that stores tasks in an in-memory List<Task> or Map<Long, Task>, injected into TaskController via constructor injection.
  2. Implement full CRUD on /api/tasks: GET /api/tasks (list all), GET /api/tasks/{id}, POST /api/tasks, PUT /api/tasks/{id}, and DELETE /api/tasks/{id}.
  3. Define a validated CreateTaskRequest DTO with @NotBlank on title and a @Size limit on description; apply it with @Valid @RequestBody on the create (and update) endpoint(s). Never accept or return the Task record directly — map to/from a TaskResponse DTO.
  4. Return the correct status from every endpoint using ResponseEntity: 201 Created with a Location header on POST, 200 OK on successful reads/updates, 204 No Content on delete.
  5. Create a TaskNotFoundException thrown by TaskService when an id doesn't exist, and a @RestControllerAdvice class that maps it to 404 and maps validation failures to 400 — both returned as the same ApiError JSON shape.
Hint

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?

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?

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?

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?

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.