REST APIs & the Web Layer

The web layer is where your service meets the outside world, and it’s where 90% of the mistakes that make study partners cringe happen: leaking exceptions, forgetting validation, hand-rolling error responses that don’t match RFC 7807, @RequestBody classes that are actually JPA entities. This file is the shape of a @RestController you’d be proud to hand off, in modern Spring 6 / Boot 3.4 style.

You will not build “the perfect REST API.” You will build one that is correct, validated, documented, and testable. That is enough to beat 80% of production Java code in the wild.


1. @RestController vs @Controller

@RestController = @Controller + @ResponseBody. Every method returns a serialized body (JSON by default). Use @Controller only when you’re rendering server-side templates (Thymeleaf) — which for the roadmap you are not.

@RestController
@RequestMapping("/api/v1/urls")
public class UrlController {

    private final UrlService urls;

    public UrlController(UrlService urls) {
        this.urls = urls;
    }

    @PostMapping
    public ResponseEntity<UrlResponse> create(@Valid @RequestBody CreateUrlRequest req) {
        var created = urls.create(req);
        return ResponseEntity
            .created(URI.create("/api/v1/urls/" + created.code()))
            .body(created);
    }

    @GetMapping("/{code}")
    public UrlResponse get(@PathVariable String code) {
        return urls.findByCode(code)
            .orElseThrow(() -> new UrlNotFoundException(code));
    }
}

Notes worth staring at:

  • Constructor injection. Same rule as everywhere.

  • Return ResponseEntity when you need to control status/headers, otherwise return the body directly and let Spring pick 200.

  • @Valid triggers Bean Validation on the request body. Without it, your @NotBlank annotations do nothing.

  • Throw domain exceptions, don’t hand-craft ResponseEntity.status(404). Let a @RestControllerAdvice translate them (below).


2. Request DTOs, Response DTOs, and the “Don’t Expose the Entity” Rule

Never, ever, use your JPA entity as a @RequestBody type or a controller return type. It is the single most common mistake, and it opens three doors:

  1. Over-posting. Client sends {"role": "admin"} and now your User entity has role=admin. Congratulations.

  2. Serialization surprises. Hibernate lazy proxies, PersistentBag, bidirectional relationships cause Jackson to blow up or infinite-loop.

  3. Schema drift. Change a column, break every client.

Use records as DTOs. They’re one line each and Jackson serializes them cleanly:

public record CreateUrlRequest(
    @NotBlank @Size(max = 2048)
    @URL(protocol = "https")
    String targetUrl,

    @Size(max = 30) @Pattern(regexp = "[a-z0-9-]*")
    String customAlias         // optional
) {}

public record UrlResponse(
    String code,
    String targetUrl,
    Instant createdAt,
    long clickCount
) {}

Map between DTO ↔ Entity in a service or a dedicated mapper (MapStruct is the standard: mvnw compile generates the mapper impl, no runtime reflection).


3. Bean Validation (Jakarta Validation 3.0)

The starter dep: spring-boot-starter-validation. Once present, @Valid on any controller parameter triggers validation.

Common annotations that carry their weight:

Annotation

Meaning

@NotNull

Not null. Allows empty strings.

@NotBlank

Not null, not empty, not whitespace-only. Strings only.

@NotEmpty

Not null, not empty. Strings/collections/maps/arrays.

@Size(min, max)

Length/size bounds.

@Min(x) / @Max(x)

Numeric bounds.

@Positive / @PositiveOrZero

Sign checks.

@Email

Basic email regex. Weak, but fine as a first filter.

@Pattern(regexp = ...)

Custom regex.

@Past / @Future

Date/time bounds.

For cross-field or business-rule validation, write a custom constraint annotation + ConstraintValidator<A, T> implementation, or validate in the service layer. Don’t cram business rules into validators — they should stay format/shape checks.

Groups (rarely worth it)

Validation groups let you validate different sets of constraints in different contexts (create vs update). In practice, use separate DTOs per operation (CreateUserRequest, UpdateUserRequest) — it’s clearer and Records make it cheap.


4. Global Exception Handling with @RestControllerAdvice

Spring throws exceptions. Your business code throws exceptions. Validation throws exceptions. You want one place that converts them all to consistent JSON responses.

Spring 6’s ProblemDetail (RFC 7807) is the modern shape:

{
  "type": "https://example.com/errors/url-not-found",
  "title": "URL Not Found",
  "status": 404,
  "detail": "No shortened URL exists for code 'abc123'",
  "instance": "/api/v1/urls/abc123",
  "timestamp": "2026-07-06T14:32:00Z",
  "traceId": "0af7651916cd43dd8448eb211c80319c"
}
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(UrlNotFoundException.class)
    public ProblemDetail handleNotFound(UrlNotFoundException ex, HttpServletRequest req) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setType(URI.create("https://example.com/errors/url-not-found"));
        pd.setTitle("URL Not Found");
        pd.setInstance(URI.create(req.getRequestURI()));
        pd.setProperty("timestamp", Instant.now());
        return pd;
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
        pd.setTitle("Validation Error");
        pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
            .toList());
        return ResponseEntity.badRequest().body(pd);
    }

    @ExceptionHandler(Exception.class)   // catch-all, must be LAST in ordering
    public ProblemDetail handleUnknown(Exception ex) {
        // Log with stack trace, but do NOT leak it to the client
        log.error("Unhandled exception", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "Something went wrong. Reference id: " + MDC.get("traceId"));
    }
}

⚠️ What most people get wrong: They leak stack traces to clients in production. Log the stack trace server-side, return a generic message with a correlation ID (from OpenTelemetry / MDC) so support can grep for it. See 06_observability_metrics_logs_traces.md.


5. Content Negotiation

Spring defaults to JSON. If you need XML or something exotic, add the right message converter (spring-boot-starter-webflux or Jackson XML module). In 2026 the honest answer is: JSON only, unless a client contract forces otherwise. Don’t build multi-format support “just in case.”

Handle content type explicitly on unusual endpoints:

@GetMapping(value = "/{code}/qr", produces = MediaType.IMAGE_PNG_VALUE)
public byte[] qr(@PathVariable String code) { ... }

6. HATEOAS — the Honest Take

Spring HATEOAS exists. It’s rarely worth the complexity. The theoretical benefit (self-describing APIs, clients discover endpoints from links) never materializes in practice because 99% of clients hardcode URLs anyway. For MNC study prep: know the term, know it’s usually skipped, move on. If a hiring manager asks whether you use it, “we tried it, added _links to responses, and no client consumed them, so we simplified” is a fine answer.


7. OpenAPI / Swagger with Springdoc

Manual API docs rot within a sprint. Auto-generate from code:

<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.6.0</version>
</dependency>

That’s it. Now:

  • /v3/api-docs — OpenAPI 3 JSON

  • /swagger-ui.html — interactive UI

Enrich with annotations only when auto-detection is wrong:

@Operation(summary = "Create a short URL", description = "Generates a 6-char code or uses customAlias if provided")
@ApiResponse(responseCode = "201", description = "Created")
@ApiResponse(responseCode = "400", description = "Invalid input")
@ApiResponse(responseCode = "409", description = "Alias already taken")
@PostMapping
public ResponseEntity<UrlResponse> create(@Valid @RequestBody CreateUrlRequest req) { ... }

Turn off swagger-ui in prod:

springdoc:
  swagger-ui:
    enabled: ${SWAGGER_UI_ENABLED:false}

8. Versioning

You have three options; pick one and never mix:

  1. URI versioning (/api/v1/...) — most common, easiest to grep. Recommended default.

  2. Header versioning (Accept: application/vnd.myapp.v2+json) — cleaner theoretically, painful to test in curl.

  3. Query param versioning (?v=2) — cacheable, but ugly.

Version at the boundary, not inside the codebase. UrlControllerV1 and UrlControllerV2 can share the same underlying service.


9. Pagination

Never return an unbounded list. Ever.

@GetMapping
public Page<UrlResponse> list(
        @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable) {
    return urls.list(pageable).map(this::toResponse);
}

Spring will accept ?page=0&size=20&sort=createdAt,desc automatically. The response wraps content + total count + page metadata. Cap size server-side (custom PageableHandlerMethodArgumentResolver) or clients will request size=1000000 and OOM you.


10. Idempotency for POST

For payment-adjacent operations, accept an Idempotency-Key header. Store {key response} in Redis for 24h. Return cached response on retry. See ../08_distributed_systems_applied_integration/03_caching_and_state.md.


11. What the Controller Should NEVER Do

  • Talk to the database (that’s the repository).

  • Contain business logic (that’s the service).

  • Handle transactions (@Transactional belongs on the service).

  • Format numbers/dates for display (that’s the DTO / a serializer).

  • Perform authorization checks by hand (that’s @PreAuthorize in Spring Security).

A controller should be short, boring, and mostly delegation. If your controller method is > 15 lines, extract to a service.


Practice Exercises

  1. Build a /api/v1/books CRUD controller with proper Records for DTOs, @Valid on POST/PUT, pagination on GET list, and a @RestControllerAdvice returning ProblemDetail.

  2. Add springdoc-openapi and screenshot the Swagger UI showing your endpoints.

  3. Introduce a BookAlreadyExistsException, throw it from the service, translate it to HTTP 409 with a ProblemDetail.

  4. Write an integration test with @WebMvcTest that verifies the 400 response for a missing title field includes the field name in the error payload.


Return to README.md · Previous: 01_spring_boot_fundamentals.md · Next: 03_data_access_jpa_and_beyond.md