Skip to content
Spring Boot core 3 min read

Building REST APIs

Spring MVC makes building HTTP APIs declarative and concise. With a few annotations you map URLs to methods, bind request data, and return JSON. This page builds a complete CRUD controller from the ground up.

@RestController and request mappings

@RestController combines @Controller with @ResponseBody, so return values are serialized to the response body (JSON by default via Jackson). @RequestMapping at class level sets a common path prefix.

@RestController
@RequestMapping("/api/products")
public class ProductController {
    // handlers go here
}

Each HTTP verb has a shortcut annotation: @GetMapping, @PostMapping, @PutMapping, @PatchMapping, and @DeleteMapping.

Binding request data

Spring binds incoming data through dedicated annotations.

AnnotationSourceExample
@PathVariableURL path segment/products/42
@RequestParamQuery string?page=2&size=20
@RequestBodyRequest body (JSON)POST payload
@GetMapping("/{id}")
public Product byId(@PathVariable Long id) { ... }

@GetMapping
public List<Product> search(@RequestParam(defaultValue = "0") int page) { ... }

ResponseEntity and status codes

Returning a domain object yields 200 OK. To control status, headers, or send no body, return a ResponseEntity.

return ResponseEntity.status(HttpStatus.CREATED)
        .header("Location", "/api/products/" + saved.getId())
        .body(saved);
ScenarioStatus
Successful read200 OK
Resource created201 Created
Successful delete (no body)204 No Content
Validation failure400 Bad Request
Resource missing404 Not Found

A full CRUD controller

This controller exposes create, read, update, and delete operations over a ProductService.

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

    private final ProductService service;

    public ProductController(ProductService service) {
        this.service = service;
    }

    @GetMapping
    public List<Product> findAll() {
        return service.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> findById(@PathVariable Long id) {
        return service.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Product> create(@Valid @RequestBody ProductRequest body) {
        Product saved = service.create(body);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> update(@PathVariable Long id,
                                          @Valid @RequestBody ProductRequest body) {
        return service.update(id, body)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

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

Validation with @Valid

Spring integrates with Jakarta Bean Validation. Annotate the request DTO with constraints and the parameter with @Valid.

public record ProductRequest(
        @NotBlank String name,
        @Positive BigDecimal price,
        @Size(max = 280) String description) { }

When validation fails, Spring throws MethodArgumentNotValidException. Translate it into a clean response with @RestControllerAdvice.

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handle(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
          .forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
        return errors;
    }
}

Output:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{ "name": "must not be blank", "price": "must be greater than 0" }

Tip: Expose DTOs (records work beautifully) rather than JPA entities in your API. This decouples your wire format from your database schema and avoids lazy-loading serialization traps.

Best Practices

  • Return ResponseEntity when you need to control status, headers, or empty bodies; return plain objects otherwise.
  • Use nouns and plural resource names in URLs (/api/products), and HTTP verbs for actions.
  • Validate input with @Valid and DTOs; never trust raw request bodies.
  • Centralize error handling in a @RestControllerAdvice for consistent error responses.
  • Map exceptions to correct status codes, 404 for missing resources, 400 for bad input.
  • Keep controllers thin; delegate all logic to services.
Last updated June 1, 2026
Was this helpful?