Skip to main content
Java intermediate Lesson 52 of 58

Building REST APIs with Spring Boot

Build production-quality REST APIs with Spring Boot — request mapping, path variables, query params, request bodies, response entities, and HTTP status codes.

Spring MVC (included in spring-boot-starter-web) handles HTTP routing, JSON serialisation, and response building. It maps incoming HTTP requests to Java methods, converts JSON to objects and back, and lets you express the full HTTP contract — status codes, headers, response bodies — in plain Java. This guide shows the patterns used in production REST APIs.

Controller Basics

A controller class groups related endpoints under one URL prefix. @RestController tells Spring to serialise return values as JSON. Each method is annotated with the HTTP method it handles (@GetMapping, @PostMapping, etc.) and the path relative to the class-level @RequestMapping.

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    // Constructor injection — Spring provides the UserService bean
    public UserController(UserService userService) {
        this.userService = userService;
    }

    // GET /api/users — returns all users as a JSON array
    @GetMapping
    public List<UserResponse> getAllUsers() {
        return userService.getAllUsers();
    }

    // GET /api/users/42 — Spring extracts 42 from the path
    @GetMapping("/{id}")
    public UserResponse getUserById(@PathVariable Long id) {
        return userService.getUserById(id);
    }

    // POST /api/users — @Valid triggers Bean Validation before the method runs
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)  // sends 201 instead of the default 200
    public UserResponse createUser(@RequestBody @Valid CreateUserRequest request) {
        return userService.createUser(request);
    }

    // PUT /api/users/42 — full replacement of the resource
    @PutMapping("/{id}")
    public UserResponse updateUser(@PathVariable Long id,
                                   @RequestBody @Valid UpdateUserRequest request) {
        return userService.updateUser(id, request);
    }

    // DELETE /api/users/42 — 204 No Content is the standard response for delete
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
    }
}

Request and Response DTOs

Separating your API contract from your domain model is important for security and flexibility. Returning an entity directly can accidentally expose internal fields (like password hashes). A response DTO gives you explicit control over what the API surface looks like, independently of how the database model evolves.

// Request DTO — what the client sends; validated before reaching the service
public record CreateUserRequest(
    @NotBlank String name,
    @Email    String email,
    @Size(min = 8) String password
) {}

// Response DTO — what the API returns; never expose the entity or password hash directly
public record UserResponse(
    Long   id,
    String name,
    String email,
    LocalDateTime createdAt
) {
    // Static factory — converts the domain entity to the API response shape
    public static UserResponse from(User user) {
        return new UserResponse(user.getId(), user.getName(),
            user.getEmail(), user.getCreatedAt());
    }
}

Path Variables

Path variables identify a specific resource. Use them for the resource’s identity — the thing that makes this resource unique in the URL space.

// Single path variable — most common case
@GetMapping("/posts/{postId}")
public Post getPost(@PathVariable Long postId) { ... }

// Multiple path variables — for nested resources
@GetMapping("/users/{userId}/orders/{orderId}")
public Order getOrder(@PathVariable Long userId, @PathVariable Long orderId) { ... }

// Optional path variable — same method handles both /items and /items/electronics
@GetMapping({"/items", "/items/{category}"})
public List<Item> getItems(@PathVariable(required = false) String category) { ... }

Query Parameters

Query parameters are for optional modifiers — filtering, searching, sorting, and pagination. They don’t change which resource you’re addressing; they change how you want that resource returned. Always provide sensible defaults so callers don’t have to specify them for the common case.

// All params have defaults so /api/products works without any query string
@GetMapping("/products")
public Page<Product> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(required = false) String search,      // null if not provided
        @RequestParam(defaultValue = "name") String sortBy) {
    return productService.findAll(page, size, search, sortBy);
}

// GET /api/products?page=1&size=10&search=widget&sortBy=price

Request Body

@RequestBody tells Spring to deserialise the JSON request body into the annotated parameter. Jackson handles the conversion automatically. Pairing it with @Valid ensures the object is validated before your method runs — if validation fails, Spring throws a MethodArgumentNotValidException before the body of your method is ever reached.

@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest req) {
    OrderResponse order = orderService.create(req);
    // Return 201 with a Location header pointing to the new resource
    URI location = URI.create("/api/orders/" + order.id());
    return ResponseEntity.created(location).body(order);
}

Spring uses Jackson to deserialise JSON → Java and serialise Java → JSON automatically.

ResponseEntity — Fine-Grained Control

ResponseEntity lets you set the exact HTTP status code, response headers, and body independently. Use it when the response varies — for example, returning 200 with a body on success but 404 with no body when the resource doesn’t exist.

@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
    // map() returns 200 with body; orElse() returns 404 with no body
    return userService.findById(id)
        .map(user -> ResponseEntity.ok(UserResponse.from(user)))
        .orElse(ResponseEntity.notFound().build());
}

@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody @Valid CreateUserRequest req) {
    UserResponse created = userService.create(req);
    URI location = URI.create("/api/users/" + created.id());
    return ResponseEntity
        .created(location)        // 201 Created + Location header pointing to the new resource
        .body(created);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
    userService.delete(id);
    return ResponseEntity.noContent().build(); // 204 No Content — successful, nothing to return
}

HTTP Status Codes

Choosing the right status code communicates intent clearly to clients and intermediate layers like load balancers and CDNs.

ScenarioStatusResponseEntity
OK, returns body200ResponseEntity.ok(body)
Created, returns new resource201ResponseEntity.created(uri).body(body)
Updated/deleted, no body204ResponseEntity.noContent().build()
Bad request data400ResponseEntity.badRequest().body(error)
Unauthorized401ResponseEntity.status(401).build()
Forbidden403ResponseEntity.status(403).build()
Not found404ResponseEntity.notFound().build()
Conflict (duplicate)409ResponseEntity.status(409).body(error)
Server error500(handled by global exception handler)

Request Headers

Headers carry metadata about the request — authentication tokens, client identifiers, content negotiation. @RequestHeader binds a header value to a method parameter, with required = false for optional headers.

@GetMapping("/secure-data")
public Data getSecureData(
        @RequestHeader("Authorization") String authHeader,
        @RequestHeader(value = "X-Request-ID", required = false) String requestId) {
    // authHeader will be like "Bearer eyJ..." for JWT-secured endpoints
    // requestId may be null if the client didn't send it
}

Full CRUD Example — Product API

A complete, production-style controller showing all the patterns together:

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

    private final ProductService productService;

    // GET /api/products?page=0&size=20&category=electronics
    @GetMapping
    public ResponseEntity<List<ProductResponse>> getAll(
            @RequestParam(defaultValue = "0")   int page,
            @RequestParam(defaultValue = "20")  int size,
            @RequestParam(required = false)      String category) {
        return ResponseEntity.ok(productService.findAll(page, size, category));
    }

    // GET /api/products/5 — 200 with body or 404
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getById(@PathVariable Long id) {
        return productService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // POST /api/products — 201 Created with Location header
    @PostMapping
    public ResponseEntity<ProductResponse> create(@RequestBody @Valid CreateProductRequest req) {
        ProductResponse product = productService.create(req);
        return ResponseEntity
            .created(URI.create("/api/products/" + product.id()))
            .body(product);
    }

    // PUT /api/products/5 — full replacement, 200 or 404
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> update(@PathVariable Long id,
                                                   @RequestBody @Valid UpdateProductRequest req) {
        return productService.update(id, req)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // PATCH /api/products/5/stock?quantity=50 — partial update, 204
    @PatchMapping("/{id}/stock")
    public ResponseEntity<Void> updateStock(@PathVariable Long id,
                                             @RequestParam int quantity) {
        productService.updateStock(id, quantity);
        return ResponseEntity.noContent().build();
    }

    // DELETE /api/products/5 — 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

CORS Configuration

Browsers block cross-origin requests by default — a frontend on localhost:3000 can’t call an API on localhost:8080 without CORS headers. You configure which origins, methods, and headers are allowed. For production, list only the specific origins your frontend is deployed on.

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:3000", "https://myapp.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true); // required if the frontend sends cookies or auth headers

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

Or per-controller when you only need CORS for specific endpoints:

@CrossOrigin(origins = "http://localhost:3000")
@RestController
public class ProductController { ... }

Frequently Asked Questions

What is the difference between @Controller and @RestController?
@Controller is the base annotation for MVC controllers — by default it returns view names (templates). @RestController combines @Controller and @ResponseBody, which means every method's return value is written directly to the HTTP response body as JSON. Use @RestController for APIs.
What is the difference between @RequestParam and @PathVariable?
@PathVariable extracts a value from the URI path: /users/{id} → @PathVariable Long id. @RequestParam extracts a value from the query string: /users?page=1 → @RequestParam int page. Use path variables for resource identity; use query params for filtering, sorting, and pagination.
When should I return ResponseEntity vs just the object?
Return the plain object when the response is always 200 OK — Spring wraps it automatically. Return ResponseEntity when you need to control the HTTP status code, response headers, or return different types depending on the outcome (e.g. 200 with body vs 404 with error body).