Updated Jun 22, 2026

Building a REST API: Controllers

In Phase 2 you learned how Spring builds and wires your objects for you — the container, beans, and dependency injection. So far those objects have just sat there, fully wired but waiting. This phase is where they finally do something visible: respond to an HTTP request.

The mental model to hold onto is this: a controller is the doorway between the outside world and your code. HTTP requests arrive at your running app from browsers, mobile apps, and other services — and something has to catch each one, figure out what it's asking for, run the right code, and send a reply. That "something" is a controller. You don't write the part that listens on a socket, parses HTTP, or formats the response bytes — Spring does all of that. You write small methods and label them so Spring knows "when a GET /api/books comes in, call this one." This is the same inversion of control from Phase 2: you don't call Spring, Spring calls you.

The running example for this whole guide is a tiny book API — a service that lets clients list, fetch, and add books. A book is just four fields:

public class Book {
    private Long id;
    private String title;
    private String author;
    private String isbn;
    // constructor, getters, and setters omitted for brevity
}

What just happened: That's the entity we'll move around for the rest of the phase — an id, a title, an author, and an isbn. Nothing Spring-specific about it yet; it's a plain Java object. (If terms like HTTP method, status code, or JSON body feel fuzzy, the REST APIs Explained and HTTP & JSON API Basics guides cover the protocol side; here we focus on the Spring side.)

@RestController and request mapping

📝 A controller is a class whose methods handle incoming requests. You mark the class with an annotation, and Spring registers each handler method against a URL and an HTTP method. When a matching request arrives, Spring calls your method and turns whatever you return into the HTTP response.

For a JSON API, the annotation you want is @RestController. It's actually two annotations rolled into one: @Controller (this class handles web requests) plus @ResponseBody (whatever a method returns is the response body, serialized to JSON — not the name of an HTML page to render). That @ResponseBody part is the whole reason @RestController exists: it says "I'm building an API, not a website."

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController
public class BookController {

    @GetMapping("/api/books")
    public List<Book> listBooks() {
        return List.of(
            new Book(1L, "The Pragmatic Programmer", "Hunt & Thomas", "9780201616224"),
            new Book(2L, "Clean Code", "Robert C. Martin", "9780132350884")
        );
    }
}

What just happened: @RestController told Spring this class catches HTTP requests and returns response bodies directly. @GetMapping("/api/books") mapped this method to GET /api/books. When that request arrives, Spring calls listBooks(), gets back a List<Book>, and — because this is a @RestController — hands that list to Jackson, the JSON library Spring Boot includes by default. Jackson reads each book's fields and produces JSON automatically. You never wrote a line of serialization code.

A request and the response it produces:

GET /api/books HTTP/1.1
Host: localhost:8080
[
  { "id": 1, "title": "The Pragmatic Programmer", "author": "Hunt & Thomas", "isbn": "9780201616224" },
  { "id": 2, "title": "Clean Code", "author": "Robert C. Martin", "isbn": "9780132350884" }
]

What just happened: The list of Book objects came back as a JSON array, one object per book, each field mapped by name. That field-by-field translation is Jackson doing its job — your method just returned plain Java objects.

💡 If you find yourself repeating /api/books at the start of every mapping, you can hoist it to the class with @RequestMapping("/api/books") and then write @GetMapping, @GetMapping("/{id}"), etc. on the methods. Same result, less repetition. We'll keep the full paths on each method here so every example reads on its own.

Path variables and request params

A real API needs to address one specific book and to filter a list. Those are two different jobs, and Spring has a different tool for each.

📝 A path variable is part of the URL path itself — /api/books/2 means "the book whose id is 2." You write a placeholder in the mapping with curly braces (/api/books/{id}) and bind it to a method parameter with @PathVariable. A request param is a query-string value after the ?/api/books?author=Martin — and you bind it with @RequestParam. The rule of thumb: a path variable identifies a resource; a request param modifies or filters a request.

import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
public class BookController {

    @GetMapping("/api/books/{id}")
    public Book getBook(@PathVariable Long id) {
        return findById(id);   // look the book up (a real lookup arrives in Phase 5)
    }

    @GetMapping("/api/books")
    public List<Book> listBooks(@RequestParam(required = false) String author) {
        if (author == null) {
            return findAll();
        }
        return findByAuthor(author);   // filter when ?author=... is present
    }
}

What just happened: In getBook, the {id} in the path lines up with the @PathVariable Long id parameter — Spring pulls the 2 out of /api/books/2, converts the text to a Long for you, and passes it in. In listBooks, @RequestParam(required = false) String author reads the ?author=... query string; required = false means the param is optional, so author is null when the client omits it, and we return the full list. One method, two behaviors, driven by the query string.

Try both with curl:

curl http://localhost:8080/api/books/2
curl "http://localhost:8080/api/books?author=Robert%20C.%20Martin"
{"id":2,"title":"Clean Code","author":"Robert C. Martin","isbn":"9780132350884"}

[{"id":2,"title":"Clean Code","author":"Robert C. Martin","isbn":"9780132350884"}]

What just happened: The first call hit the path-variable route and returned a single book object. The second call hit the same /api/books endpoint but, because ?author= was present, returned a filtered array. The quotes around the second URL keep the shell from choking on the ? and the space (encoded as %20).

Request bodies (POST)

Reading is half an API. To create a book, the client sends data in the request body, and your method needs to receive it.

📝 @PostMapping maps a method to POST, the HTTP method for "create this." The new book arrives as JSON in the request body, and @RequestBody is the annotation that says "take that JSON and turn it into a Java object for me." Jackson runs in reverse here: it reads the incoming JSON and constructs a Book, matching JSON keys to fields by name.

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

@RestController
public class BookController {

    @PostMapping("/api/books")
    public Book createBook(@RequestBody Book book) {
        Book saved = save(book);   // persist it (real persistence comes in Phase 5)
        return saved;              // echo the created book back to the client
    }
}

What just happened: @PostMapping("/api/books") routed POST /api/books to this method. @RequestBody Book book told Spring to deserialize the JSON body into a Book — Jackson read the keys and filled in the fields. We "save" it (a placeholder for now) and return the saved book, which Spring serializes straight back to JSON. The client sends a book and gets the stored version back, typically now carrying its assigned id.

The request the client sends:

{
  "title": "Domain-Driven Design",
  "author": "Eric Evans",
  "isbn": "9780321125217"
}

What just happened: The client posts a book with no id — the server assigns that. Jackson binds title, author, and isbn onto the Book object before your method body ever runs, so by the time createBook executes, book is a fully populated Java object you can work with.

ResponseEntity and status codes

Every example so far has quietly returned 200 OK, because that's what Spring does when you return a plain object. But "200" isn't always the honest answer. Creating a resource should report 201 Created; asking for a book that doesn't exist should report 404 Not Found. To control the status code (and headers), you return a ResponseEntity instead of the bare object.

📝 A ResponseEntity<T> is a wrapper around your response body that also carries the status code and headers. Return book and you get a default 200; return ResponseEntity.status(201).body(book) and you've said exactly what status and what body the client should see.

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

@RestController
public class BookController {

    @PostMapping("/api/books")
    public ResponseEntity<Book> createBook(@RequestBody Book book) {
        Book saved = save(book);
        return ResponseEntity.status(201).body(saved);   // 201 Created, body is the new book
    }

    @GetMapping("/api/books/{id}")
    public ResponseEntity<Book> getBook(@PathVariable Long id) {
        Book found = findById(id);
        if (found == null) {
            return ResponseEntity.notFound().build();    // 404, no body
        }
        return ResponseEntity.ok(found);                 // 200, body is the book
    }
}

What just happened: createBook now returns a ResponseEntity with status 201 and the new book as the body — the client learns the resource was created, not merely fetched. getBook checks whether the book exists: if not, ResponseEntity.notFound().build() produces a clean 404 with no body; otherwise ResponseEntity.ok(found) returns 200 with the book. Contrast this with the earlier methods that returned a plain Book and always got 200 — ResponseEntity is the switch you flip when the default status isn't the truth you want to tell.

💡 Use a plain return type when 200 is genuinely correct (a simple list or lookup that always succeeds), and reach for ResponseEntity when the status varies — creation, deletion, not-found, or anything where the client should react differently based on the code. Both are valid; pick the one that says what you mean.

How the request flows

It's worth seeing the whole path a request takes, because it demystifies what feels like magic.

flowchart TD
  A[HTTP request arrives] --> B[DispatcherServlet]
  B --> C[Match URL + method to a controller method]
  C --> D[Bind path vars, params, and @RequestBody]
  D --> E[Call your method]
  E --> F[Jackson serializes the return value to JSON]
  F --> G[HTTP response sent back]

📝 At the front of every Spring web app sits one object you never wrote: the DispatcherServlet. Every request hits it first. It looks at the URL and HTTP method, finds the controller method whose mapping matches, binds the inputs (pulls the id from the path, the author from the query string, the JSON from the body — converting types as it goes), and then calls your method. Whatever you return goes back through Jackson to become the JSON response. Your job is just the middle box: one focused method.

💡 Notice how thin these controller methods are. The best ones read the request, hand off to something that does the real work, and shape the response — that's it. In these examples save, findById, and findByAuthor are placeholders, but in a real app that work belongs in a separate service layer, which Phase 6 introduces: a BookService bean, injected into the controller the way Phase 2 showed, that holds the actual logic. The controller speaks HTTP; the service holds the rules. Keeping that line clean is what keeps a growing API maintainable.

⚠️ Don't put business logic or database calls directly in a controller. It's tempting to dump a query or a pricing calculation right into the handler because it "works." It does — until that logic needs testing (you'd need a fake HTTP request to exercise it), reusing (a scheduled job can't send itself a request), or changing (and now every change touches the doorway). A controller that does two jobs becomes the place every bug hides. Keep it to HTTP in, HTTP out, and delegate the rest.

Recap

  1. @RestController makes a class an HTTP doorway. It's @Controller + @ResponseBody, so whatever your methods return is serialized to JSON by Jackson and sent back as the response body.
  2. Mapping annotations route requests to methods. @GetMapping("/api/books") handles GET /api/books, @PostMapping("/api/books") handles POST /api/books; Spring calls your method when a matching request arrives.
  3. Path variables identify, request params filter. @PathVariable binds {id} from the URL path (one specific book); @RequestParam binds query-string values like ?author=... (filter or modify), and can be optional with required = false.
  4. @RequestBody turns incoming JSON into an object. On a POST, Jackson deserializes the request body into a Book before your method runs, so you work with a populated Java object.
  5. ResponseEntity controls status and headers. Return a plain object for a default 200, or a ResponseEntity for 201 Created, 404 Not Found, and anything else where the status is part of the answer.
  6. The flow: DispatcherServlet → match URL + method → bind inputs → call your (thin) method → Jackson serializes the result → response. ⚠️ Keep business logic and DB calls out of the controller — that's the service layer's job, coming in Phase 6.

Quick check

Make sure the core controller ideas stuck:

[
  {
    "q": "What does @RestController add on top of plain @Controller?",
    "choices": [
      "@ResponseBody behavior — method return values are serialized straight to the response body (JSON), instead of being treated as view/page names",
      "It connects the class to the database automatically",
      "It makes every method run inside a transaction",
      "Nothing — @RestController and @Controller are identical"
    ],
    "answer": 0,
    "explain": "@RestController is @Controller + @ResponseBody. The @ResponseBody part means whatever a method returns IS the response body (serialized to JSON by Jackson), which is exactly what you want for an API rather than rendering an HTML view."
  },
  {
    "q": "You want to fetch one specific book by its id from the URL /api/books/2. Which annotation binds that 2 to your method parameter?",
    "choices": [
      "@PathVariable, because the id is part of the URL path and identifies a specific resource",
      "@RequestParam, because all URL values use the same annotation",
      "@RequestBody, because the id travels in the request body",
      "@GetMapping, because that annotation reads the value for you"
    ],
    "answer": 0,
    "explain": "A value embedded in the path (/api/books/{id}) is bound with @PathVariable — it identifies a resource. @RequestParam is for query-string values after the ? (like ?author=...), which filter or modify a request. @RequestBody is for the JSON body of a POST."
  },
  {
    "q": "Your create endpoint returns a plain Book object. A client reports it always gets HTTP 200 even though a resource was created. How do you return 201 Created instead?",
    "choices": [
      "Return a ResponseEntity, e.g. ResponseEntity.status(201).body(saved), which carries the status code alongside the body",
      "Add @PostMapping(status = 201) to the method",
      "Throw an exception after saving so Spring picks a different code",
      "Nothing can change it — controllers can only return 200"
    ],
    "answer": 0,
    "explain": "Returning a bare object gives the default 200. To control the status (and headers), wrap the body in a ResponseEntity — ResponseEntity.status(201).body(saved) reports 201 Created. The same tool gives you 404 via ResponseEntity.notFound().build()."
  }
]

← Phase 2: Dependency Injection & Beans · Guide overview · Phase 4: Configuration & Profiles →

Check your understanding

1. What does @RestController add on top of plain @Controller?

2. You want to fetch one specific book by its id from the URL /api/books/2. Which annotation binds that 2 to your method parameter?

3. Your create endpoint returns a plain Book object. A client reports it always gets HTTP 200 even though a resource was created. How do you return 201 Created instead?

Was this page helpful?