Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.


← Spring Boot REST API Development

Spring Boot Basics

  • What is Spring Boot?
  • Dependency Injection
  • Building REST Controllers

Validation & Error Handling

  • Bean Validation with @Valid
  • Global Exception Handling

Spring Framework Internals

  • IoC Container Fundamentals
  • Bean Lifecycle In Detail
  • Component Scanning & Configuration
  • Auto-Configuration Mechanism
  • Spring AOP
  • @Transactional Deep Dive

Microservices Architecture

  • Monolith to Microservices Decomposition
  • API Gateway
  • Service Discovery
  • Inter-Service Communication Choices
  • Event-Driven Architecture Patterns
  • Messaging Technology Choices

Resilience Patterns

  • Configuration Management
  • Circuit Breaker Pattern
  • Retry & Backoff Strategies
  • Bulkhead & Rate Limiting
  • Timeout Strategy
  • Why Microservices Fail

Distributed Data & Consistency Patterns

  • Two-Phase Commit
  • Saga Pattern
  • Outbox Pattern
  • Eventual Consistency Design
  • CQRS Basics
  • CAP Theorem

Observability & Operations

  • Centralized Logging
  • Distributed Tracing
  • Metrics & Monitoring
  • Alerting Strategy
  • Health Checks

Payment Systems

  • Payment — Requirements
  • Payment — Core Flow
  • Payment — Idempotency Implementation
  • Payment — Failure Handling & Reconciliation
  • Payment — Security

Order Management System

  • OMS — Requirements
  • OMS — State Machine Design
Chaturmind
← Spring Boot REST API Development

Spring Boot Basics

  • What is Spring Boot?
  • Dependency Injection
  • Building REST Controllers

Validation & Error Handling

  • Bean Validation with @Valid
  • Global Exception Handling

Spring Framework Internals

  • IoC Container Fundamentals
  • Bean Lifecycle In Detail
  • Component Scanning & Configuration
  • Auto-Configuration Mechanism
  • Spring AOP
  • @Transactional Deep Dive

Microservices Architecture

  • Monolith to Microservices Decomposition
  • API Gateway
  • Service Discovery
  • Inter-Service Communication Choices
  • Event-Driven Architecture Patterns
  • Messaging Technology Choices

Resilience Patterns

  • Configuration Management
  • Circuit Breaker Pattern
  • Retry & Backoff Strategies
  • Bulkhead & Rate Limiting
  • Timeout Strategy
  • Why Microservices Fail

Distributed Data & Consistency Patterns

  • Two-Phase Commit
  • Saga Pattern
  • Outbox Pattern
  • Eventual Consistency Design
  • CQRS Basics
  • CAP Theorem

Observability & Operations

  • Centralized Logging
  • Distributed Tracing
  • Metrics & Monitoring
  • Alerting Strategy
  • Health Checks

Payment Systems

  • Payment — Requirements
  • Payment — Core Flow
  • Payment — Idempotency Implementation
  • Payment — Failure Handling & Reconciliation
  • Payment — Security

Order Management System

  • OMS — Requirements
  • OMS — State Machine Design
HomeLearnSpring BootSpring Boot REST API DevelopmentSpring Boot Basics
✓ FreeBeginner· 9 min read

Building REST Controllers

@RestController, path variables, request bodies — build clean REST endpoints.

Published September 21, 2026


Building REST Controllers

A REST controller is the class that receives HTTP requests and decides what to return. In Spring Boot it's the outermost layer of your application: it turns a request (GET /api/v1/products/42) into a Java method call, and turns that method's return value back into an HTTP response (status code, headers, JSON body).

A good controller is thin. It reads the request, hands the real work to a service, and shapes the response. Business rules, database access and transactions belong in the layers below it. Keeping that boundary is what makes controllers easy to test and easy to change.

A complete CRUD controller

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {

    private final ProductService productService;

    // Constructor injection: the dependency is required and the field can be final
    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public List<ProductDto> getAllProducts() {
        return productService.findAll();
    }

    @GetMapping("/{id}")
    public ProductDto getProduct(@PathVariable String id) {
        return productService.findById(id);          // throws ResourceNotFoundException → 404
    }

    @PostMapping
    public ResponseEntity<ProductDto> createProduct(@RequestBody @Valid CreateProductRequest request) {
        ProductDto created = productService.create(request);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);   // 201 + Location header
    }

    @PutMapping("/{id}")
    public ProductDto replaceProduct(@PathVariable String id,
                                     @RequestBody @Valid UpdateProductRequest request) {
        return productService.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteProduct(@PathVariable String id) {
        productService.delete(id);
    }
}

What each annotation does:

  • @RestController marks the class as a web controller and says every method's return value is the response body. It is literally @Controller + @ResponseBody.
  • @RequestMapping("/api/v1/products") on the class sets a common path prefix for every method.
  • @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping are shortcuts for @RequestMapping(method = ...).
  • @ResponseStatus sets a fixed status code when you don't need a ResponseEntity.

Getting data out of the request

A request can carry data in four places, and each has its own annotation:

// Path variable: part of the URL path identifies the resource
@GetMapping("/{id}")                                   // GET /products/42
public ProductDto get(@PathVariable String id) { ... }

// Query parameters: optional filters, sorting, paging
@GetMapping                                            // GET /products?category=books&page=0&size=20
public Page<ProductDto> search(@RequestParam(required = false) String category,
                               @RequestParam(defaultValue = "0") int page,
                               @RequestParam(defaultValue = "20") int size) {
    return productService.search(category, PageRequest.of(page, size));
}

// Request body: the JSON document being created or updated
@PostMapping
public ProductDto create(@RequestBody @Valid CreateProductRequest request) { ... }

// Headers: metadata such as idempotency keys or API versions
@PostMapping("/orders")
public OrderDto placeOrder(@RequestHeader("Idempotency-Key") String key,
                           @RequestBody @Valid PlaceOrderRequest request) { ... }

A simple rule for choosing: the path identifies which resource, query parameters describe how you want a collection (filtered, sorted, paged), the body carries the resource itself, and headers carry metadata about the request. Putting a filter in the path (/products/category/books) or an ID in the body of a GET is a common design smell interviewers notice.

For paging, Spring can build the Pageable for you: declare a Pageable pageable parameter and it reads page, size and sort from the query string.

What happens between the HTTP request and your method

Knowing this flow is what separates "I use the annotations" from "I understand Spring MVC":

HTTP request
   │
   ▼
Servlet filters (security, CORS, logging)      ← run before Spring MVC; exceptions here skip @ControllerAdvice
   │
   ▼
DispatcherServlet                              ← the single front controller for every request
   │  1. HandlerMapping: which controller method matches this path + HTTP method?
   │  2. Argument resolvers: build each parameter (@PathVariable, @RequestBody via Jackson, Pageable...)
   │  3. Validation: @Valid parameters are checked here
   ▼
Your controller method
   │
   ▼
Return value handling                          ← HttpMessageConverter (Jackson) writes the object as JSON
   │
   ▼
HTTP response (status, headers, body)

@RequestBody works because an HttpMessageConverter (Jackson, by default) reads the JSON into your class. On the way out, the same converter writes your return value as JSON. Which format is used depends on the request's Content-Type and Accept headers. This is called content negotiation, and it's why a missing Content-Type: application/json header produces a 415 Unsupported Media Type.

Choosing status codes and methods correctly

MethodTypical useSuccess statusIdempotent?
GETRead a resource or collection200 OKYes
POSTCreate a resource, or trigger an action201 Created (+ Location header) or 200/202No
PUTReplace a resource entirely200 OK (or 204)Yes
PATCHChange some fields of a resource200 OKNot guaranteed
DELETERemove a resource204 No ContentYes

Idempotent means sending the same request twice leaves the server in the same state as sending it once. Retrying a PUT or DELETE after a network error is safe. Retrying a POST may create a duplicate, which is why payment and order APIs add an idempotency key header.

The common error statuses, and where they come from in a Spring app:

  • 400 Bad Request: validation failed or the JSON couldn't be parsed.
  • 404 Not Found: the service threw a "not found" exception, mapped by your exception handler.
  • 409 Conflict: the request clashes with current state (duplicate slug, stale version).
  • 415 Unsupported Media Type / 406 Not Acceptable: content negotiation failed.
  • 500 Internal Server Error: anything unexpected. Never leak the stack trace to the client.

Returning a ResponseEntity vs a plain object

Returning a plain object is enough when the status is always the same. Use ResponseEntity when you need to choose the status at runtime or add headers:

@GetMapping("/{id}")
public ResponseEntity<ProductDto> get(@PathVariable String id) {
    return productService.findOptional(id)
            .map(ResponseEntity::ok)                     // 200 with body
            .orElseGet(() -> ResponseEntity.notFound().build());   // 404, no body
}

In larger apps it's usually cleaner to throw a ResourceNotFoundException from the service and let a global @RestControllerAdvice turn it into a 404 (see Global Exception Handling). Then every endpoint reports "not found" in the same shape.

Never return your entities directly

Controllers should accept and return DTOs (data transfer objects), not your database entities:

  • Security: an entity can contain fields the client must never see (password hashes, internal flags).
  • Stability: renaming a database field would silently change your public API.
  • Over-posting: binding a request straight into an entity lets a client set fields they shouldn't, such as role=ADMIN or price=0.

Records make DTOs cheap to write: public record ProductDto(String id, String name, BigDecimal price) {}.

Common mistakes

  • Business logic in the controller: calculating prices or checking stock in the controller means it can't be reused by a scheduled job or a message consumer. Put it in the service.
  • Catching exceptions in every method and hand-building error responses. Throw, and handle centrally in one @RestControllerAdvice.
  • Returning 200 for everything, including errors with an "error" field in the body. Clients, caches and monitoring rely on real status codes.
  • Unbounded list endpoints: GET /products returning 2 million rows. Page every collection endpoint from the start.
  • Using @Controller by mistake: Spring then tries to resolve your return value as a view name and you get a confusing template error.

Testing a controller in isolation

@WebMvcTest starts only the web layer (controllers, filters, converters), with the service mocked:

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired MockMvc mvc;
    @MockBean ProductService productService;

    @Test
    void returns404WhenProductMissing() throws Exception {
        when(productService.findById("42")).thenThrow(new ResourceNotFoundException("Product", "42"));

        mvc.perform(get("/api/v1/products/42"))
           .andExpect(status().isNotFound());
    }
}

This checks routing, JSON serialization, validation and error mapping in milliseconds, without a database.

Follow-up questions this topic invites — and their answers

Q: What is the difference between @Controller and @RestController? A: @RestController is @Controller plus @ResponseBody. With plain @Controller, a returned String is treated as the name of a view template to render. With @RestController, the return value is serialized straight into the response body, usually as JSON. Use @Controller for server-rendered HTML pages and @RestController for APIs.

Q: PUT or PATCH for an update endpoint? A: PUT replaces the whole resource: fields missing from the body are reset, and repeating it is safe. PATCH changes only the fields sent. It's more convenient for clients but needs care, because you must tell "field not sent" apart from "field set to null", and JSON Merge Patch or JSON Patch formats exist for exactly that. Many teams expose PUT for full updates and add PATCH only where partial updates are genuinely needed.

Q: Why return 201 Created with a Location header instead of 200? A: 201 tells the client (and any tooling) that a new resource now exists. The Location header gives its URL, so the client doesn't have to guess or build it. It's the HTTP-standard way to answer a successful create.

Q: How would you version this API? A: The most common approach is a version in the path (/api/v1/...). It's visible, easy to route and easy to cache. Alternatives are a header (Accept: application/vnd.company.v2+json) or a query parameter. Whatever you choose, keep old versions running while clients migrate, and make only additive, backward-compatible changes within one version.

Q: How are @PathVariable and @RequestParam values converted from strings to types like int or UUID? A: Spring's conversion service converts the raw string to the parameter's type. If conversion fails (/products/abc for an int id), Spring throws a MethodArgumentTypeMismatchException. Map that to a 400 in your exception handler, otherwise it can surface as a 500.

Q: Where should authorization checks go: controller or service? A: Coarse checks ("must be logged in", "must have ADMIN role") are fine at the controller or URL level with Spring Security, for example @PreAuthorize("hasRole('ADMIN')"). Checks that depend on data ("can this user edit this order?") usually belong in the service, where the data is loaded. That way they also protect non-HTTP entry points.

Previous

Dependency Injection

Next

Bean Validation with @Valid

AI Tutor

Lesson: Building REST Controllers

Quick actions

AI responses can be inaccurate. Verify critical information.