Spring BootREST Controller Basics
Spring Boot

REST Controller Basics

A REST controller in Spring Boot is a class annotated with @RestController that handles HTTP requests and returns serialized response bodies. This entry covers the anatomy of a controller, request mapping, HTTP method annotations, return types, and the difference between @Controller and @RestController.

Anatomy of a REST Controller

A REST controller is thin — it handles HTTP concerns only and delegates all logic to a service. The class-level annotations establish the base path; method-level annotations map each operation to an HTTP verb and URI template. Fields are injected via constructor injection.
Java
@RestController                          // (1) marks class as REST controller
@RequestMapping("/api/v1/users")         // (2) base path for all methods
@RequiredArgsConstructor                 // (3) Lombok constructor injection
@Validated                               // (4) enables parameter-level validation
@Slf4j                                   // (5) injects logger
public class UserController {

    private final UserService userService; // (6) injected service

    @GetMapping                          // GET  /api/v1/users
    public ResponseEntity<Page<UserResponse>> findAll(
            @PageableDefault(size = 20) Pageable pageable) {
        return ResponseEntity.ok(userService.findAll(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();
    }
}

@Controller vs @RestController

@Controller is the base MVC annotation — handler methods return view names resolved to templates by a ViewResolver. @RestController combines @Controller and @ResponseBody, making every method serialize its return value directly to the HTTP response body. Use @RestController for all REST API endpoints.
Java
// ── @Controller — view-based ──────────────────────────────────────────
@Controller
public class PageController {

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

    // Mix-in: one method returns JSON
    @GetMapping("/api/users")
    @ResponseBody           // this method only — serialize to JSON
    public List<UserResponse> usersJson() {
        return userService.findAll();
    }
}

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

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

// ── When to use which ─────────────────────────────────────────────────
// @RestController        → REST API endpoints (JSON / XML)
// @Controller            → Server-side rendering (Thymeleaf, Freemarker)
// @Controller + select @ResponseBody → rare mixed MVC + API controller

HTTP Method Annotations

Spring provides composed mapping annotations for each HTTP verb. Each is a shortcut for @RequestMapping(method = RequestMethod.X). Use the composed annotations — they are more readable and communicate intent at a glance.
Java
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    // ── GET — retrieve resource(s) ────────────────────────────────────
    @GetMapping                  // collection
    public List<OrderResponse> findAll() { ... }

    @GetMapping("/{id}")         // single resource
    public OrderResponse findById(@PathVariable Long id) { ... }

    // ── POST — create a new resource ──────────────────────────────────
    @PostMapping
    public ResponseEntity<OrderResponse> create(
            @RequestBody @Valid CreateOrderRequest req,
            UriComponentsBuilder ucb) {
        OrderResponse created = orderService.create(req);
        return ResponseEntity.created(
            ucb.path("/api/v1/orders/{id}")
               .buildAndExpand(created.id()).toUri())
            .body(created);
    }

    // ── PUT — full replacement ─────────────────────────────────────────
    @PutMapping("/{id}")
    public OrderResponse replace(
            @PathVariable Long id,
            @RequestBody @Valid ReplaceOrderRequest req) {
        return orderService.replace(id, req);
    }

    // ── PATCH — partial update ─────────────────────────────────────────
    @PatchMapping("/{id}")
    public OrderResponse patch(
            @PathVariable Long id,
            @RequestBody @Valid PatchOrderRequest req) {
        return orderService.patch(id, req);
    }

    // ── DELETE — remove resource ───────────────────────────────────────
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        orderService.delete(id);
        return ResponseEntity.noContent().build();
    }

    // ── HEAD — same as GET but no body (check existence / headers) ────
    @RequestMapping(value = "/{id}", method = RequestMethod.HEAD)
    public ResponseEntity<Void> exists(@PathVariable Long id) {
        orderService.findById(id);   // throws 404 if absent
        return ResponseEntity.ok().build();
    }

    // ── OPTIONS — advertise allowed methods ───────────────────────────
    @RequestMapping(value = "/{id}", method = RequestMethod.OPTIONS)
    public ResponseEntity<Void> options() {
        return ResponseEntity.ok()
            .allow(HttpMethod.GET, HttpMethod.PUT,
                   HttpMethod.PATCH, HttpMethod.DELETE)
            .build();
    }
}

ResponseEntity

ResponseEntity<T> gives full control over the HTTP response — status code, headers, and body. Return it whenever you need to set a non-200 status, add response headers, or return an empty body. For simple 200 responses where no headers are needed, returning T directly is acceptable.
Java
@RestController
@RequestMapping("/api/v1/documents")
public class DocumentController {

    // ── 200 OK with body ──────────────────────────────────────────────
    @GetMapping("/{id}")
    public ResponseEntity<DocumentResponse> findById(@PathVariable Long id) {
        return ResponseEntity.ok(docService.findById(id));
    }

    // ── 201 Created with Location header ─────────────────────────────
    @PostMapping
    public ResponseEntity<DocumentResponse> create(
            @RequestBody @Valid CreateDocumentRequest req,
            UriComponentsBuilder ucb) {
        DocumentResponse doc = docService.create(req);
        return ResponseEntity.created(
            ucb.path("/api/v1/documents/{id}")
               .buildAndExpand(doc.id()).toUri())
            .body(doc);
    }

    // ── 204 No Content ────────────────────────────────────────────────
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        docService.delete(id);
        return ResponseEntity.noContent().build();
    }

    // ── 206 Partial Content ───────────────────────────────────────────
    @GetMapping("/{id}/content")
    public ResponseEntity<byte[]> content(
            @PathVariable Long id,
            @RequestHeader(value = HttpHeaders.RANGE,
                           required = false) String range) {
        return docService.getContent(id, range);
    }

    // ── Custom headers ────────────────────────────────────────────────
    @GetMapping("/{id}/export")
    public ResponseEntity<Resource> export(@PathVariable Long id) {
        Resource file = docService.export(id);
        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    "attachment; filename="doc-" + id + ".pdf"")
            .header(HttpHeaders.CACHE_CONTROL,
                    "no-cache, no-store, must-revalidate")
            .contentType(MediaType.APPLICATION_PDF)
            .body(file);
    }

    // ── Conditional response (ETag / Last-Modified) ───────────────────
    @GetMapping(value = "/{id}", headers = "If-None-Match")
    public ResponseEntity<DocumentResponse> findByIdCached(
            @PathVariable Long id,
            @RequestHeader("If-None-Match") String etag) {
        DocumentResponse doc = docService.findById(id);
        String currentEtag = """ + doc.version() + """;
        if (currentEtag.equals(etag)) {
            return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
        }
        return ResponseEntity.ok()
            .eTag(currentEtag)
            .body(doc);
    }
}

Controller Design Rules

A well-designed REST controller is thin, stateless, and focused on HTTP concerns only. These rules keep controllers maintainable as the API grows.
Java
// ── Rule 1: No business logic in controllers ─────────────────────────

// WRONG — repository and logic directly in controller:
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody CreateUserRequest req) {
    if (userRepository.existsByEmail(req.email())) {
        throw new ConflictException("Email taken");
    }
    User user = new User(req.name(), req.email());
    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 ucb) {
    UserResponse created = userService.create(req);
    return ResponseEntity.created(
        ucb.path("/api/v1/users/{id}")
           .buildAndExpand(created.id()).toUri())
        .body(created);
}

// ── Rule 2: One controller per resource ───────────────────────────────
// UserController    → /users
// OrderController   → /orders
// ProductController → /products

// ── Rule 3: Never expose JPA entities directly ────────────────────────
// WRONG: public User findById(...)          → exposes internals
// CORRECT: public UserResponse findById(...)→ returns DTO

// ── Rule 4: Constructor injection, not field injection ────────────────
// WRONG:  @Autowired private UserService userService;
// CORRECT: private final UserService userService; + @RequiredArgsConstructor

// ── Rule 5: Version in the base path ──────────────────────────────────
@RequestMapping("/api/v1/users")

// ── Rule 6: Explicit ResponseEntity for non-200 responses ─────────────
// Return ResponseEntity<T> whenever the status code is not 200 OK
// so it is visible and reviewable at the method signature level.