REST APIs

Reviewed & published by Brayan K

REST APIs are how modern apps talk to each other. By the end of this lesson you'll build a real CRUD API with Spring Boot — mapping HTTP verbs to methods, returning correct status codes, validating input, and handling errors cleanly. This is one of the most marketable Java skills you can have.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ REST in Plain English

A REST API exposes your data as resources living at URLs — /api/users is the whole collection, /api/users/42 is one user. You act on a resource with an HTTP verb, and the server replies with a status code plus (usually) JSON.

💡 Analogy: A REST API is like a restaurant. Each endpoint is a dish on the menu (a resource). You use different verbs to interact — GET (read the menu), POST (place an order), PUT (change your order), DELETE (cancel it). The waiter (the server) answers with a status code: 200 (here's your food), 201 (order created), 404 (we don't serve that), 500 (the kitchen caught fire).

The golden rule: the URL names the thing, the verb names the action. So /api/getUser?id=42 is wrong — the verb is hiding in the URL. The RESTful version is GET /api/users/42.

MethodEndpointActionTypical status
GET/api/usersList all users200 OK
GET/api/users/42Get user #42200 / 404
POST/api/usersCreate a user201 Created
PUT/api/users/42Replace user #42200 / 404
DELETE/api/users/42Delete user #42204 No Content

2️⃣ Your First @RestController (CRUD)

Mark a class @RestController and Spring treats its methods as web handlers that return JSON. Add @RequestMapping("/api/users") to set a shared URL prefix, then map each verb to a method: @GetMapping, @PostMapping, @PutMapping, @DeleteMapping.

Three annotations pull data out of the request: @PathVariable grabs the 42 from /api/users/42, @RequestParam reads query parameters after the ?, and @RequestBody parses the JSON body of a POST/PUT into a Java object.

Wrap the return value in ResponseEntity<T> when the status code varies. Returning a user gives 200 OK; ResponseEntity.status(HttpStatus.CREATED) gives 201 after a create; .notFound().build() gives 404; .noContent().build() gives 204 after a delete.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.*;
import java.util.concurrent.atomic.AtomicLong;

// A record is an immutable data carrier. Spring turns it into JSON for you.
record User(Long id, String name, String email) {}

// @RestController = "this class handles web requests and returns JSON".
// @RequestMapping sets the shared URL prefix for every method below.
@RestController
@RequestMapping("/api/users")
public class UserController {

    // An in-memory map standing in for a database (the JDBC lesson is the real thing).
    private final Map<Long, User> store = new LinkedHashMap<>();
    private final AtomicLong nextId = new AtomicLong(1);

    // GET /api/users  ->  200 OK with the whole list as a JSON array
    @GetMapping
    public List<User> getAll() {
        return new ArrayList<>(store.values());
    }

    // GET /api/users/42  ->  200 with the user, or 404 if there is no #42
    @GetMapping("/{id}")
    public ResponseEntity<User> getById(@PathVariable Long id) {
        User user = store.get(id);                 // @PathVariable pulls 42 out of the URL
        return user != null
                ? ResponseEntity.ok(user)          // 200 OK + body
                : ResponseEntity.notFound().build(); // 404 Not Found, no body
    }

    // POST /api/users  ->  201 Created (the correct code for "I made a new thing")
    @PostMapping
    public ResponseEntity<User> create(@RequestBody User input) {
        long id = nextId.getAndIncrement();        // @RequestBody parses the JSON body into a User
        User user = new User(id, input.name(), input.email());
        store.put(id, user);
        return ResponseEntity.status(HttpStatus.CREATED).body(user); // 201, not 200
    }

    // DELETE /api/users/42  ->  204 No Content (success, nothing to return)
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        store.remove(id);
        return ResponseEntity.noContent().build(); // 204
    }
}

🎯 Your Turn #1 — GET One Resource (200 or 404)

Finish the three blanks: declare the path variable in the mapping, bind it from the URL, and return 200 OK with the body. The expected requests and responses are in the comments so you can check yourself.

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;

record Book(Long id, String title) {}

@RestController
@RequestMapping("/api/books")
public class BookController {
    private final Map<Long, Book> store = Map.of(1L, new Book(1L, "Effective Java"));

    // 🎯 YOUR TURN — fill in the blanks marked with ___

    // GET /api/books/{id} -> 200 with the book, or 404 if it doesn't exist.
    @GetMapping("/___")                      // 👉 add the path variable segment: {id}
    public ResponseEntity<Book> getById(@___ Long id) {  // 👉 bind id FROM the URL path
        Book book = store.get(id);
        return book != null
                ? ResponseEntity.___(book)   // 👉 200 OK with the book as the body
                : ResponseEntity.notFound().build();
    }

    // ✅ Expected:
    //   GET /api/books/1   -> 200 {"id":1,"title":"Effective Java"}
    //   GET /api/books/99  -> 404 Not Found
}

3️⃣ The Service Layer and DTOs

A controller's only job is HTTP — read the request, call something, shape the response. The actual work (rules, calculations, talking to the database) belongs in a @Service class. Spring creates the service and passes it into the controller via constructor injection, so you never new it yourself. Thin controller + fat service is the pattern that keeps real apps testable.

Just as important: never return your database entity directly. Entities often carry sensitive fields — a password hash, internal flags — and serializing the whole thing leaks them to every client. Instead, map the entity to a DTO (Data Transfer Object): a small record holding only the fields the client is allowed to see.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

import java.util.*;

// The ENTITY — the full database shape, including the password hash.
record UserEntity(Long id, String name, String email, String passwordHash) {}

// The DTO (Data Transfer Object) — the SAFE shape the client is allowed to see.
// Note: no passwordHash. This is how you avoid leaking sensitive fields.
record UserDto(Long id, String name, String email) {}

// @Service holds the business logic. Keeping it OUT of the controller means the
// controller only deals with HTTP, and the logic can be tested on its own.
@Service
class UserService {
    private final Map<Long, UserEntity> store = new LinkedHashMap<>();

    UserService() {
        store.put(1L, new UserEntity(1L, "Alice", "[email protected]", "$2a$hashed"));
    }

    // Throws if missing — the controller turns that into a 404 (see exception lesson below).
    public UserDto findById(Long id) {
        UserEntity e = store.get(id);
        if (e == null) throw new NoSuchElementException("User " + id + " not found");
        return toDto(e);                              // map entity -> DTO before returning
    }

    private UserDto toDto(UserEntity e) {
        return new UserDto(e.id(), e.name(), e.email()); // passwordHash deliberately dropped
    }
}

@RestController
@RequestMapping("/api/users")
class UserController {
    private final UserService service;

    // Constructor injection: Spring passes the UserService in automatically.
    UserController(UserService service) { this.service = service; }

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getById(@PathVariable Long id) {
        return ResponseEntity.ok(service.findById(id)); // 200 with the safe DTO
    }
}

4️⃣ Validation and Global Error Handling

Never trust client input. Put Bean Validation annotations on a request record to describe what "valid" means, then add @Valid to the @RequestBody parameter. Spring checks the rules before your method runs and automatically rejects bad input with 400 Bad Request.

@NotBlank — must not be null or empty/whitespace

@Size(min = 2, max = 50) — string length constraints

@Email — must be a well-formed email address

@Min(0) @Max(150) — numeric range (inclusive)

For everything else, centralize error handling in one @ControllerAdvice class. Each @ExceptionHandler method catches an exception type and turns it into a clean HTTP response — a validation failure into 400, a missing resource into 404 — so every endpoint reports errors in the same JSON shape and your controllers stay focused on the happy path.

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.*;

// Bean Validation annotations describe the RULES for valid input.
// @Valid below makes Spring enforce them BEFORE your method runs.
record CreateUserRequest(
        @NotBlank @Size(min = 2, max = 50) String name,   // not empty, 2-50 chars
        @Email String email,                              // must look like an email
        @Min(0) @Max(150) int age) {}                     // 0..150 inclusive

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

    @PostMapping
    public ResponseEntity<String> create(@Valid @RequestBody CreateUserRequest req) {
        // Execution only reaches here if EVERY rule passed.
        return ResponseEntity.status(HttpStatus.CREATED)
                .body("Created: " + req.name());
    }
}

// @ControllerAdvice = one global place to turn exceptions into HTTP responses,
// so every endpoint returns errors in the SAME shape. (@RestControllerAdvice adds JSON.)
@RestControllerAdvice
class ApiExceptionHandler {

    // Validation failed -> collect {field: message} and return 400 Bad Request.
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
        return errors;                                    // -> 400 with a field->message map
    }

    // A missing resource -> 404 Not Found with a tidy JSON body.
    @ExceptionHandler(NoSuchElementException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Map<String, String> handleNotFound(NoSuchElementException ex) {
        return Map.of("error", ex.getMessage());
    }
}

🎯 Your Turn #2 — POST That Returns 201 Created

Fill the three blanks: the annotation that handles POST, the one that reads the JSON body, and the status that means "created". The expected request and response are in the comments.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

record Task(Long id, String title) {}

@RestController
@RequestMapping("/api/tasks")
public class TaskController {
    private long nextId = 1;

    // 🎯 YOUR TURN — fill in the blanks marked with ___

    // POST /api/tasks  ->  201 Created with the new task.
    @___                                     // 👉 the annotation for handling POST
    public ResponseEntity<Task> create(@___ Task input) {  // 👉 read the JSON request body
        Task saved = new Task(nextId++, input.title());
        return ResponseEntity.status(HttpStatus.___).body(saved); // 👉 the "Created" status
    }

    // ✅ Expected:
    //   POST /api/tasks  {"title":"Ship it"}
    //   -> 201 Created   {"id":1,"title":"Ship it"}
}

Common Errors (and the Fix)

🧩 Mini-Challenge — A Notes API

Now write a small CRUD controller yourself from an outline. Build POST (201), GET-by-id (200 or 404), and DELETE (204) for a Note resource, using @RequestBody, @PathVariable, and ResponseEntity with the correct status codes. The expected requests and responses are in the comments.

import org.springframework.web.bind.annotation.*;

// 🎯 MINI-CHALLENGE: a notes API
// 1. Make a record Note(Long id, String text) and a @RestController on "/api/notes".
// 2. Keep notes in a Map<Long, Note> with an incrementing id.
// 3. POST /api/notes        -> 201 Created, body is the saved Note (use @RequestBody).
// 4. GET  /api/notes/{id}   -> 200 with the Note, or 404 (use @PathVariable + ResponseEntity).
// 5. DELETE /api/notes/{id} -> 204 No Content.
//
// ✅ Expected:
//   POST {"text":"Buy milk"} -> 201 {"id":1,"text":"Buy milk"}
//   GET  /api/notes/1        -> 200 {"id":1,"text":"Buy milk"}
//   GET  /api/notes/9        -> 404
//   DELETE /api/notes/1      -> 204

// your code here

📋 Quick Reference — HTTP Verbs & Annotations

VerbAnnotationPurposeSuccess status
GET@GetMappingRead a resource or list200 OK
POST@PostMappingCreate a new resource201 Created
PUT@PutMappingReplace a resource200 OK
DELETE@DeleteMappingRemove a resource204 No Content
—@PathVariableRead a value from the URL path/users/{id}
—@RequestParamRead a query-string parameter?role=admin
—@RequestBodyParse the JSON request bodyPOST/PUT
—@ValidEnforce Bean Validation rules400 on failure
—@ControllerAdviceGlobal exception → HTTP mapping404 / 400 / 500

🎉 Lesson Complete!

Great work! You can now build a production-quality REST API with Spring Boot: map verbs with @GetMapping/@PostMapping/@PutMapping/@DeleteMapping, read input with @PathVariable/@RequestParam/@RequestBody, return correct status codes with ResponseEntity, keep logic in a @Service, hide sensitive fields behind DTOs, validate with @Valid, and handle errors globally with @ControllerAdvice.

Practice quiz

What is the RESTful URL for getting user #42?

  • GET /api/getUser?id=42
  • POST /api/users/42
  • GET /api/users/42
  • GET /api/user/get/42

Answer: GET /api/users/42. The URL names the resource and the verb names the action: GET /api/users/42. Verbs do not belong in the path.

Which annotation makes a class return JSON from every method automatically?

  • @RestController
  • @Controller
  • @Service
  • @Component

Answer: @RestController. @RestController is @Controller + @ResponseBody, so each method serializes its return value to JSON.

Which annotation reads the JSON payload of a POST request into a Java object?

  • @PathVariable
  • @RequestParam
  • @ModelAttribute
  • @RequestBody

Answer: @RequestBody. @RequestBody deserializes the JSON body of a POST/PUT into your record or class.

Which annotation pulls the 42 out of /api/users/42?

  • @RequestParam
  • @PathVariable
  • @RequestBody
  • @RequestHeader

Answer: @PathVariable. @PathVariable binds a value that is part of the URL path. @RequestParam reads query parameters after the '?'.

What status code should a successful POST that creates a resource return?

  • 201 Created
  • 200 OK
  • 204 No Content
  • 404 Not Found

Answer: 201 Created. Creating a new resource should return 201 Created, not a plain 200.

What status code is correct for a successful DELETE with no body to return?

  • 200 OK
  • 201 Created
  • 204 No Content
  • 400 Bad Request

Answer: 204 No Content. 204 No Content signals success with nothing to return — the right code after a delete.

Why return ResponseEntity<T> instead of the object directly?

  • It is faster
  • It lets you set the status code, headers, and whether there's a body
  • It is required by Spring
  • It automatically validates input

Answer: It lets you set the status code, headers, and whether there's a body. Returning the object always sends 200. ResponseEntity lets the status vary (201, 204, 404) and add headers.

Why map a database entity to a DTO before returning it?

  • DTOs are faster to serialize
  • Spring requires DTOs
  • To make the entity immutable
  • To avoid leaking sensitive fields like a password hash to clients

Answer: To avoid leaking sensitive fields like a password hash to clients. Returning the entity serializes every field. A DTO exposes only the fields the client is allowed to see.

How do you make Spring validate a request body before your method runs?

  • Call validate() manually
  • Add @Valid to the @RequestBody parameter (with Bean Validation annotations on the record)
  • Wrap it in a try/catch
  • Use @Controller instead of @RestController

Answer: Add @Valid to the @RequestBody parameter (with Bean Validation annotations on the record). @Valid on the @RequestBody triggers the Bean Validation rules (@NotBlank, @Email, @Min...); Spring returns 400 on failure.

What is @ControllerAdvice (or @RestControllerAdvice) used for?

  • Injecting services
  • Mapping URLs to methods
  • One global place to turn exceptions into consistent HTTP error responses
  • Defining database entities

Answer: One global place to turn exceptions into consistent HTTP error responses. @ControllerAdvice with @ExceptionHandler methods centralizes error handling so every endpoint returns errors in the same shape.

Continue this course

Frequently asked questions

What is a REST API, and what does REST actually mean?

A REST API is a way for programs to talk over HTTP by treating your data as resources at URLs — /api/users is the collection, /api/users/42 is one user. REST (Representational State Transfer) is a set of conventions: you act on those resources with the standard HTTP verbs (GET to read, POST to create, PUT to replace, DELETE to remove) and the server answers with a status code and usually JSON. The big idea is that the URL names the thing and the verb names the action, so /api/getUser?id=42 is un-RESTful — it should be GET /api/users/42.

What is the difference between @Controller and @RestController?

@Controller is the classic Spring MVC annotation: its methods return a view name (like an HTML template), and you add @ResponseBody to a method when you want it to return data instead. @RestController is simply @Controller + @ResponseBody applied to the whole class, so every method automatically serializes its return value to JSON. For building APIs you almost always want @RestController.

When do I use @PathVariable, @RequestParam, and @RequestBody?

Use @PathVariable for a value that is part of the URL path and identifies a resource: /api/users/42 -> @PathVariable Long id. Use @RequestParam for optional query-string parameters after the ?: /api/users?role=admin&page=2 -> @RequestParam String role. Use @RequestBody for the JSON payload sent with POST and PUT requests; Spring deserializes that body into your record or class. A rule of thumb: path = which resource, query = how to filter/page it, body = the data you are sending.

Why return ResponseEntity instead of just the object?

Returning the object directly always sends 200 OK, which is wrong for many cases — creating should be 201, deleting should be 204, and a missing resource should be 404. ResponseEntity<T> lets you set the status code, add headers (like Location after a create), and choose whether there's a body. Use a plain return type for simple always-200 reads, and ResponseEntity when the status genuinely varies.

What's the right way to handle errors and validation in a REST API?

Validate input with Bean Validation annotations (@NotBlank, @Email, @Min, @Size) on a request record and trigger them with @Valid on the @RequestBody parameter — Spring then rejects bad input with a 400 before your method runs. For everything else, centralize error handling in one @ControllerAdvice (or @RestControllerAdvice) class with @ExceptionHandler methods, so a missing resource becomes a 404 and an unexpected failure becomes a 500, all in a consistent JSON shape. This keeps your controllers focused on the happy path.

Related lessons