Spring Boot Course · Lesson 2

Returning JSON & Modelling a Book

In Lesson 1 you returned a plain String. Real APIs return data — structured objects, as JSON. Here you'll model your first domain type, the Book, and watch Spring turn Java objects and lists into JSON automatically.

The win By the end, GET /api/books returns a JSON array of books and GET /api/books/featured returns a single book object — your first real API resource.

Picking up where Lesson 1 left off

Recall the key idea from last time: @RestController = @Controller + @ResponseBody. The @ResponseBody part means "whatever this method returns IS the HTTP response body" — not the name of an HTML page to render. When you returned a String, it went back as plain text.

Return an object instead, and Spring does something smarter: it serialises it to JSON. JSON (JavaScript Object Notation) is the lingua franca of REST APIs — a compact, language-neutral text format that every client, from a browser to a mobile app, can read. Our job in this lesson is just to hand Spring the right Java objects; it handles the conversion.

The one mental model to keep You never write JSON by hand in a Spring controller. You return ordinary Java objects; a library called Jackson (bundled by the web starter) serialises them to JSON on the way out. Your code stays in Java; the wire stays in JSON.

Step 1 — Model the Book

A REST API is organised around resources — the nouns your API exposes. Ours is the Book. Before we write it, a word on where code lives.

How we'll organise the project: package by feature Everything for one feature lives in one package. The Book record, its controller, and (later) its service and repository all go in com.example.library.book; authors get their own com.example.library.author package in a later lesson. The alternative — grouping by technical layer (a controller package, a service package, …) — is common in tutorials, but package-by-feature keeps everything you touch for one change side by side, and it scales better as the API grows.

One rule makes this work: @SpringBootApplication scans its own package and every package below it. Because book sits under com.example.library (where LibraryApplication lives), your classes are discovered automatically. Put code in a sibling or parent package and component scanning silently misses it.

Now the record itself. Because you know Java well, we'll use a record — a perfect fit for a simple, immutable data carrier:

src/main/java/com/example/library/book/Book.java

package com.example.library.book;

import java.util.UUID;

public record Book(UUID id, String title, String author, int year) {
}

That single line gives you a constructor, accessors (id(), title(), …), plus equals/hashCode/toString. Jackson reads those accessors to discover the fields it should turn into JSON — so a record works out of the box. (A plain class with getters works identically; the record is just less typing.)

Why UUID for the id? The most common default in Spring tutorials is a numeric Long that the database auto-increments. We're using a UUID instead: a 128-bit globally-unique identifier. It can be generated by the app (no round-trip to the database to learn the id), it's not sequentially guessable (so clients can't enumerate your books by counting 1, 2, 3…), and it stays unique even if you later merge data from multiple sources. The cost is a larger, less human-friendly id — a trade we're happy to make here. Jackson serialises a UUID as a JSON string, which you'll see in a moment. When we reach persistence in Lesson 5, the database will generate these for us.

Step 2 — Return a single object

Now a controller that hands back one Book. Create:

src/main/java/com/example/library/book/BookController.java

package com.example.library.book;

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

@RestController
public class BookController {

    @GetMapping("/api/books/featured")
    public Book featured() {
        return new Book(UUID.fromString("11111111-1111-1111-1111-111111111111"),
                "Clean Code", "Robert C. Martin", 2008);
    }
}

Run the app (./mvnw spring-boot:run) and open http://localhost:8080/api/books/featured. You'll see:

{ "id": "11111111-1111-1111-1111-111111111111", "title": "Clean Code", "author": "Robert C. Martin", "year": 2008 }

You returned a Java object and got JSON. Notice the field names match your record components exactly — that's Jackson mapping each accessor to a JSON key. The id comes out as a quoted string: that's how Jackson renders a UUID, since JSON has no native UUID type.

Step 3 — Return a list

APIs rarely return one thing. Add a second method that returns a List<Book> — Spring serialises collections to a JSON array just as readily:

src/main/java/com/example/library/book/BookController.java (add the import + method)

import java.util.List;
import java.util.UUID;
// ...inside the class:

    @GetMapping("/api/books")
    public List<Book> all() {
        return List.of(
            new Book(UUID.fromString("11111111-1111-1111-1111-111111111111"), "Clean Code", "Robert C. Martin", 2008),
            new Book(UUID.fromString("22222222-2222-2222-2222-222222222222"), "Effective Java", "Joshua Bloch", 2018),
            new Book(UUID.fromString("33333333-3333-3333-3333-333333333333"), "The Pragmatic Programmer", "Hunt & Thomas", 1999)
        );
    }

Restart and open http://localhost:8080/api/books:

[
  { "id": "11111111-1111-1111-1111-111111111111", "title": "Clean Code", "author": "Robert C. Martin", "year": 2008 },
  { "id": "22222222-2222-2222-2222-222222222222", "title": "Effective Java", "author": "Joshua Bloch", "year": 2018 },
  { "id": "33333333-3333-3333-3333-333333333333", "title": "The Pragmatic Programmer", "author": "Hunt & Thomas", "year": 1999 }
]

The data is hard-coded for now — that's deliberate. We're focused on the web layer (request in, JSON out). A real data store arrives in Lesson 5 (Spring Data JPA); the layer that holds this list moves into a service in Lesson 3.

Step 4 — Tidy up with a base path

Both routes start with /api/books. Repeating that on every method is noise — and a typo waiting to happen. Lift the shared prefix to the class with @RequestMapping, and let each method declare only the part that's unique to it:

src/main/java/com/example/library/book/BookController.java

package com.example.library.book;

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

@RestController
@RequestMapping("/api/books")   // base path for every method in this class
public class BookController {

    @GetMapping                 // GET /api/books
    public List<Book> all() {
        return List.of(
            new Book(UUID.fromString("11111111-1111-1111-1111-111111111111"), "Clean Code", "Robert C. Martin", 2008),
            new Book(UUID.fromString("22222222-2222-2222-2222-222222222222"), "Effective Java", "Joshua Bloch", 2018),
            new Book(UUID.fromString("33333333-3333-3333-3333-333333333333"), "The Pragmatic Programmer", "Hunt & Thomas", 1999)
        );
    }

    @GetMapping("/featured")    // GET /api/books/featured
    public Book featured() {
        return new Book(UUID.fromString("11111111-1111-1111-1111-111111111111"),
                "Clean Code", "Robert C. Martin", 2008);
    }
}

Spring concatenates the class-level path with each method-level path. The endpoints behave exactly as before, but the routing now reads cleanly: this class owns the /api/books resource. The /api prefix is a common convention that keeps your data endpoints distinct from any pages or static files.

How does the object actually become JSON? When your method returns, Spring MVC asks its HTTP message converters to write the value to the response. The web starter auto-configures one backed by Jackson; it serialises your object to JSON and sets the Content-Type: application/json header. This is the same @ResponseBody machinery from Lesson 1 — a String just took a different, plain-text converter. The reverse direction (JSON in → Java object) is deserialization, which you'll meet in Lesson 4 when clients POST new books.

Step 5 — Inspect it like an API client

The browser shows JSON, but it hides the headers. Use curl -i from the terminal to see the full HTTP response — status line, headers, and body:

curl -i http://localhost:8080/api/books
HTTP/1.1 200
Content-Type: application/json
...

[{"id":"11111111-1111-1111-1111-111111111111","title":"Clean Code",...}]

That Content-Type: application/json is the proof: Spring chose the JSON converter for you. Deciding which format to send (here, JSON) based on the response type and the client's request is called content negotiation.

If it doesn't work

Check yourself

Retrieval beats re-reading. Answer from memory before clicking.

You return a Book object from a @RestController method. What turns it into JSON?
What does @RequestMapping("/api/books") on the class do?
Return a List<Book> and what does the client receive?
Why was a record a convenient choice for Book?

Read this next

Primary source: the Spring reference on @ResponseBody and HTTP message conversion — this is the official description of the exact mechanism you just used. Short and worth skimming to cement how return values become JSON.

New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.