Skip to main content

    Lesson 35 • Advanced

    Building REST APIs with Spring Boot

    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.

    What You'll Learn in This Lesson

    • Map HTTP verbs to methods with @GetMapping/@PostMapping/@PutMapping/@DeleteMapping
    • Read input with @PathVariable, @RequestParam, and @RequestBody
    • Return the right status codes (200, 201, 204, 400, 404) with ResponseEntity
    • Split logic into a thin controller and a @Service layer
    • Validate requests with @Valid and Bean Validation annotations
    • Centralize error handling with @ControllerAdvice and DTOs

    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.

    Worked Example: CRUD @RestController
    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
        }
    }
    Output
    $ curl -i -X POST localhost:8080/api/users \
           -H "Content-Type: application/json" \
           -d '{"name":"Alice","email":"alice@test.com"}'
    HTTP/1.1 201 Created
    Content-Type: application/json
    {"id":1,"name":"Alice","email":"alice@test.com"}
    
    $ curl -i localhost:8080/api/users
    HTTP/1.1 200 OK
    Content-Type: application/json
    [{"id":1,"name":"Alice","email":"alice@test.com"}]
    
    $ curl -i localhost:8080/api/users/999
    HTTP/1.1 404 Not Found
    
    $ curl -i -X DELETE localhost:8080/api/users/1
    HTTP/1.1 204 No Content
    This is real Spring Boot code. It runs inside a Spring Boot app (started with mvn spring-boot:run), which boots an embedded web server — so there's no console output. The panel above shows the HTTP request you send and the JSON response you get back. Try it on start.spring.io (add the Spring Web + Validation dependencies), then call it with curl.

    🎯 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.

    Your Turn: Fill in the Blanks
    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
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.

    Worked Example: Service Layer + DTO (hiding fields)
    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", "alice@test.com", "$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
        }
    }
    Output
    $ curl -i localhost:8080/api/users/1
    HTTP/1.1 200 OK
    Content-Type: application/json
    {"id":1,"name":"Alice","email":"alice@test.com"}
                           # notice: NO passwordHash field — the DTO hid it
    This is real Spring Boot code. It runs inside a Spring Boot app (started with mvn spring-boot:run), which boots an embedded web server — so there's no console output. The panel above shows the HTTP request you send and the JSON response you get back. Try it on start.spring.io (add the Spring Web + Validation dependencies), then call it with curl.

    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.

    Worked Example: @Valid + @ControllerAdvice
    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());
        }
    }
    Output
    $ curl -i -X POST localhost:8080/api/users \
           -H "Content-Type: application/json" \
           -d '{"name":"A","email":"not-an-email","age":200}'
    HTTP/1.1 400 Bad Request
    Content-Type: application/json
    {"name":"size must be between 2 and 50",
     "email":"must be a well-formed email address",
     "age":"must be less than or equal to 150"}
    
    $ curl -i -X POST localhost:8080/api/users \
           -H "Content-Type: application/json" \
           -d '{"name":"Bob","email":"bob@test.com","age":30}'
    HTTP/1.1 201 Created
    Created: Bob
    This is real Spring Boot code. It runs inside a Spring Boot app (started with mvn spring-boot:run), which boots an embedded web server — so there's no console output. The panel above shows the HTTP request you send and the JSON response you get back. Try it on start.spring.io (add the Spring Web + Validation dependencies), then call it with curl.

    🎯 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.

    Your Turn: Create a Resource
    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"}
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    Common Errors (and the Fix)

    • Always returning 200: sending 200 OK for a create, a delete, or a missing resource lies to the client and breaks tooling that keys off status codes. Fix: return 201 on create, 204 on delete, 404 when not found, 400 for bad input — use ResponseEntity to set them.
    • No validation: trusting @RequestBody input lets empty names, junk emails, and absurd numbers straight into your data. Fix: annotate the request record (@NotBlank, @Email, @Min/@Max) and add @Valid to the body parameter so Spring returns 400 automatically.
    • Exposing entities directly: returning your database entity serializes every field — including passwordHash or internal flags — and leaks it to clients. Fix: map the entity to a DTO that contains only the fields the client should see.
    • No error handling: an uncaught exception falls through to Spring's default whitebox 500 with a stack trace, and clients get no usable message. Fix: add a @ControllerAdvice with @ExceptionHandler methods so each failure maps to a sensible status and a consistent JSON body.
    • Verbs in the URL: POST /api/createUser or GET /api/getUser?id=42 duplicates the HTTP method in the path. Fix: let the verb carry the action — POST /api/users and GET /api/users/42.

    🧩 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.

    Mini-Challenge: Your Code Here
    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
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    📋 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

    ❓ 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.

    🎉 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.

    Next up: JSON & XML — the serialization formats your API uses to turn objects into the data clients receive.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service