Spring BootREST Controller
Spring Boot

REST Controller

@RestController is the central annotation for building REST APIs in Spring Boot. It combines @Controller and @ResponseBody, making every handler method serialize its return value directly to the HTTP response body. This entry covers the full anatomy of a REST controller — from annotation setup through request mapping, parameter binding, response shaping, and exception handling.

@RestController vs @Controller

@Controller is the base annotation for Spring MVC controllers. By default it returns view names — strings resolved to templates by a ViewResolver. @ResponseBody on a method (or @RestController on the class) switches the return value to be serialized directly into the HTTP response body via an HttpMessageConverter. @RestController is a composed annotation that combines @Controller and @ResponseBody. It is the correct choice for any controller that serves JSON or XML to HTTP clients rather than rendering HTML templates.
Java
// ── @Controller — view-based (not for REST APIs) ─────────────────────
@Controller
public class PageController {

    @GetMapping("/users")
    public String usersPage(Model model) {
        model.addAttribute("users", userService.findAll());
        return "users";   // resolved to a template: users.html
    }

    @GetMapping("/api/users")
    @ResponseBody   // this one method returns JSON, not a view name
    public List<UserResponse> usersJson() {
        return userService.findAll();
    }
}

// ── @RestController — every method returns serialized body ────────────
@RestController   // = @Controller + @ResponseBody on every method
@RequestMapping("/users")
public class UserController {

    @GetMapping
    public List<UserResponse> findAll() {
        return userService.findAll();   // serialized to JSON — no @ResponseBody needed
    }

    @GetMapping("/{id}")
    public UserResponse findById(@PathVariable Long id) {
        return userService.findById(id);
    }
}

// ── When to use which ─────────────────────────────────────────────────
// @RestController  — REST API endpoints returning JSON/XML
// @Controller      — Server-side rendering (Thymeleaf, Freemarker, JSP)
// @Controller + @ResponseBody on select methods — mixed MVC + API controller
//                   (uncommon; prefer separate controllers)

Anatomy of a REST Controller

A production REST controller is thin — it handles HTTP concerns only and delegates all logic to a service. The class-level annotations establish the base path and JSON content type; method-level annotations map each operation to an HTTP verb and URI.
Java
@RestController                          // (1) mark as REST controller
@RequestMapping("/api/v1/users")         // (2) base path for all methods
@RequiredArgsConstructor                 // (3) constructor injection via Lombok
@Validated                               // (4) enable parameter-level Bean Validation
public class UserController {

    private final UserService userService; // (5) injected service — no business logic here

    // (6) Handler methods — one per operation:

    @GetMapping                          // GET /api/v1/users
    public ResponseEntity<Page<UserResponse>> findAll(
            @PageableDefault(size = 20) Pageable pageable,
            @RequestParam(required = false) String search) {
        return ResponseEntity.ok(userService.findAll(search, pageable));
    }

    @GetMapping("/{id}")                 // GET /api/v1/users/{id}
    public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @PostMapping                         // POST /api/v1/users
    public ResponseEntity<UserResponse> create(
            @RequestBody @Valid CreateUserRequest request,
            UriComponentsBuilder uriBuilder) {
        UserResponse created = userService.create(request);
        URI location = uriBuilder.path("/api/v1/users/{id}")
            .buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")                 // PUT /api/v1/users/{id}
    public ResponseEntity<UserResponse> update(
            @PathVariable Long id,
            @RequestBody @Valid UpdateUserRequest request) {
        return ResponseEntity.ok(userService.update(id, request));
    }

    @PatchMapping("/{id}")               // PATCH /api/v1/users/{id}
    public ResponseEntity<UserResponse> patch(
            @PathVariable Long id,
            @RequestBody @Valid PatchUserRequest request) {
        return ResponseEntity.ok(userService.patch(id, request));
    }

    @DeleteMapping("/{id}")              // DELETE /api/v1/users/{id}
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Content Negotiation and Media Types

By default @RestController serializes responses to JSON using Jackson. Adding the Jackson XML extension enables XML serialization without changing controller code — Spring selects the serializer based on the client's Accept header. The produces and consumes attributes on mapping annotations narrow which media types a handler accepts.
XML
<!-- Enable XML support alongside JSON — add to pom.xml: -->
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
</dependency>

// ── Controller — handles both JSON and XML automatically ───────────────
@RestController
@RequestMapping(value = "/users",
                produces = {MediaType.APPLICATION_JSON_VALUE,
                            MediaType.APPLICATION_XML_VALUE})
public class UserController {

    @GetMapping("/{id}")
    public UserResponse findById(@PathVariable Long id) {
        return userService.findById(id);
        // GET /users/42, Accept: application/json → JSON response
        // GET /users/42, Accept: application/xml  → XML response
    }
}

// ── Restrict a single method to one media type ────────────────────────
@GetMapping(value = "/{id}/card",
            produces = MediaType.TEXT_HTML_VALUE)
public String userCard(@PathVariable Long id, Model model) {
    // Returns an HTML fragment — only this method, not the whole controller
    return "<div>" + userService.findById(id).name() + "</div>";
}

// ── Restrict request body format ──────────────────────────────────────
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE,
             produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UserResponse> create(
        @RequestBody @Valid CreateUserRequest request) {
    return ResponseEntity.status(201).body(userService.create(request));
}

// ── Custom media type ─────────────────────────────────────────────────
@GetMapping(value = "/{id}",
            produces = "application/vnd.myapp.user.v2+json")
public UserResponseV2 findByIdV2(@PathVariable Long id) {
    return userService.findByIdV2(id);
}

Exception Handling in REST Controllers

Exception handling belongs in a @RestControllerAdvice class, not in individual controllers. This centralizes error response formatting, keeps controllers clean, and ensures every exception maps to a consistent error body structure.
Java
// ── Standardised error response ──────────────────────────────────────
public record ErrorResponse(
    int status,
    String error,
    String message,
    Instant timestamp,
    Map<String, String> fieldErrors
) {
    public static ErrorResponse of(int status, String error, String message) {
        return new ErrorResponse(status, error, message, Instant.now(), Map.of());
    }
}

// ── Global exception handler ───────────────────────────────────────────
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of(404, "Not Found", ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid",
                (a, b) -> a));
        return ResponseEntity.badRequest().body(
            new ErrorResponse(400, "Validation Failed",
                "One or more fields failed validation", Instant.now(), errors));
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleUnreadable(
            HttpMessageNotReadableException ex) {
        return ResponseEntity.badRequest()
            .body(ErrorResponse.of(400, "Bad Request", "Malformed request body"));
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(
            ConstraintViolationException ex) {
        Map<String, String> errors = ex.getConstraintViolations().stream()
            .collect(Collectors.toMap(
                cv -> cv.getPropertyPath().toString(),
                ConstraintViolation::getMessage));
        return ResponseEntity.badRequest().body(
            new ErrorResponse(400, "Validation Failed",
                "Parameter validation failed", Instant.now(), errors));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
        log.error("Unhandled exception", ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Internal Server Error",
                "An unexpected error occurred"));
    }
}

Controller Design Best Practices

A well-designed REST controller is thin, stateless, and focused on HTTP concerns only. These patterns keep controllers maintainable as the API grows.
Java
// ── 1. Keep controllers thin — no business logic ──────────────────────

// WRONG — business logic in the controller:
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody @Valid CreateUserRequest req) {
    if (userRepository.existsByEmail(req.email())) {
        throw new ConflictException("Email taken");
    }
    User user = new User();
    user.setEmail(req.email());
    user.setName(req.name());
    user.setCreatedAt(LocalDateTime.now());
    userRepository.save(user);
    return ResponseEntity.status(201).body(UserResponse.from(user));
}

// CORRECT — delegate everything to the service:
@PostMapping
public ResponseEntity<UserResponse> create(
        @RequestBody @Valid CreateUserRequest req,
        UriComponentsBuilder uriBuilder) {
    UserResponse created = userService.create(req);
    URI location = uriBuilder.path("/users/{id}").buildAndExpand(created.id()).toUri();
    return ResponseEntity.created(location).body(created);
}

// ── 2. One controller per resource ────────────────────────────────────
// UserController    → /users
// OrderController   → /orders
// ProductController → /products
// Avoid mega-controllers that handle multiple unrelated resources.

// ── 3. Never expose JPA entities directly ─────────────────────────────
// WRONG:
@GetMapping("/{id}")
public User findById(@PathVariable Long id) {   // exposes entity — lazy load issues,
    return userRepository.findById(id).orElseThrow();  // no field control
}
// CORRECT: return a DTO (UserResponse record)

// ── 4. Use constructor injection, not field injection ─────────────────
// WRONG:
@Autowired private UserService userService;   // harder to test, hides dependencies

// CORRECT (with Lombok @RequiredArgsConstructor):
@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;   // immutable, explicit dependency
}

// ── 5. Version your API in the base path ─────────────────────────────
@RequestMapping("/api/v1/users")   // clear, stable, cache-friendly versioning

// ── 6. Return ResponseEntity for full HTTP control ────────────────────
// Prefer ResponseEntity<T> over plain T return types in REST controllers
// so status codes and headers are always explicit and reviewable.

A Complete REST Controller

A full, production-ready controller for a User resource — showing all CRUD operations, pagination, search, proper status codes, and Location headers in one coherent example.
Java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated
@Slf4j
public class UserController {

    private final UserService userService;

    @GetMapping
    public ResponseEntity<Page<UserResponse>> findAll(
            @PageableDefault(size = 20, sort = "createdAt",
                             direction = Sort.Direction.DESC) Pageable pageable,
            @RequestParam(required = false)
            @Size(max = 100) String search) {
        log.debug("GET /users search={} pageable={}", search, pageable);
        return ResponseEntity.ok(userService.findAll(search, pageable));
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> findById(
            @PathVariable @Positive Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @PostMapping
    public ResponseEntity<UserResponse> create(
            @RequestBody @Valid CreateUserRequest request,
            UriComponentsBuilder uriBuilder) {
        UserResponse created = userService.create(request);
        URI location = uriBuilder
            .path("/api/v1/users/{id}")
            .buildAndExpand(created.id())
            .toUri();
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> update(
            @PathVariable @Positive Long id,
            @RequestBody @Valid UpdateUserRequest request) {
        return ResponseEntity.ok(userService.update(id, request));
    }

    @PatchMapping("/{id}")
    public ResponseEntity<UserResponse> patch(
            @PathVariable @Positive Long id,
            @RequestBody @Valid PatchUserRequest request) {
        return ResponseEntity.ok(userService.patch(id, request));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(
            @PathVariable @Positive Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/{id}/orders")
    public ResponseEntity<Page<OrderResponse>> findOrders(
            @PathVariable @Positive Long id,
            @PageableDefault(size = 20) Pageable pageable) {
        return ResponseEntity.ok(orderService.findByUser(id, pageable));
    }
}