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
ResponseEntitywhen you need to control status/headers, otherwise return the body directly and let Spring pick 200.@Validtriggers Bean Validation on the request body. Without it, your@NotBlankannotations do nothing.Throw domain exceptions, don’t hand-craft
ResponseEntity.status(404). Let a@RestControllerAdvicetranslate 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:
Over-posting. Client sends
{"role": "admin"}and now yourUserentity hasrole=admin. Congratulations.Serialization surprises. Hibernate lazy proxies,
PersistentBag, bidirectional relationships cause Jackson to blow up or infinite-loop.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 |
|---|---|
|
Not null. Allows empty strings. |
|
Not null, not empty, not whitespace-only. Strings only. |
|
Not null, not empty. Strings/collections/maps/arrays. |
|
Length/size bounds. |
|
Numeric bounds. |
|
Sign checks. |
|
Basic email regex. Weak, but fine as a first filter. |
|
Custom regex. |
|
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:
URI versioning (
/api/v1/...) — most common, easiest to grep. Recommended default.Header versioning (
Accept: application/vnd.myapp.v2+json) — cleaner theoretically, painful to test in curl.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 (
@Transactionalbelongs on the service).Format numbers/dates for display (that’s the DTO / a serializer).
Perform authorization checks by hand (that’s
@PreAuthorizein Spring Security).
A controller should be short, boring, and mostly delegation. If your controller method is > 15 lines, extract to a service.
Practice Exercises¶
Build a
/api/v1/booksCRUD controller with proper Records for DTOs,@Validon POST/PUT, pagination on GET list, and a@RestControllerAdvicereturningProblemDetail.Add
springdoc-openapiand screenshot the Swagger UI showing your endpoints.Introduce a
BookAlreadyExistsException, throw it from the service, translate it to HTTP 409 with aProblemDetail.Write an integration test with
@WebMvcTestthat verifies the 400 response for a missingtitlefield 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