Learn Java

Lesson 72Phase 9: Data and the web

Your first web API with Spring Boot

Starting a project with Spring Initializr, writing controllers that answer HTTP requests, and how dependency injection wires it together.

By Updated 60 min

  • Spring Boot
  • Spring Initializr
  • @RestController
  • @GetMapping
  • @PostMapping
  • dependency injection

In the last lesson your Java program sent requests to a server. In this one, your program becomes the server. With Spring Boot, a web API that answers GET /books with a list of books in JSON takes a handful of short classes, because the framework handles the network, the threads and the conversion to JSON. You write the part that is about books.

By the end of this lesson
  • Create a Spring Boot project with Spring Initializr, run it, and read its startup log.
  • Write a controller whose methods answer GET and POST requests, with path variables, query parameters and JSON bodies.
  • Return the right status code: 200, 201 or 404.
  • Explain dependency injection: who creates your objects, and how a controller gets the service it needs.
  • Change a setting such as the port in application.properties.
From before
From HttpClient and JSON: methods, status codes, JSON and Jackson, which Spring Boot uses to turn your records into JSON. From Maven and Gradle: the pom.xml and mvn. From annotations: labels such as @Override that tools read; Spring reads its own. And from logging: the log lines Spring prints are Logback's.

What Spring Boot is#

Spring is a framework: a large library that calls your code, instead of your code calling it. Spring Boot is the quick way to start a Spring application. It picks sensible settings for you, and it puts a web server (Tomcat) inside your program, so you start it like any other Java program and it listens for HTTP requests. Most Java web services in companies are built this way.

Starting a project with Spring Initializr#

Projects start at start.spring.io, the Spring Initializr. Choose Maven, Java, Spring Boot 4.1.1, a group (com.example), an artifact (library), Java 25, and one dependency, Spring Web. Press Generate and you get a zip with a ready project. Its pom.xmlstarts from Spring Boot's parent, which fixes the versions of everything, and asks for one starter, a dependency that pulls in a whole set of libraries that work together:

pom.xml (the parts that matter)
read along
1<parent>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-parent</artifactId>
4 <version>4.1.1</version>
5</parent>
6...
7<dependencies>
8 <dependency>
9 <groupId>org.springframework.boot</groupId>
10 <artifactId>spring-boot-starter-webmvc</artifactId>
11 </dependency>
12 <dependency>
13 <groupId>org.springframework.boot</groupId>
14 <artifactId>spring-boot-starter-webmvc-test</artifactId>
15 <scope>test</scope>
16 </dependency>
17</dependencies>

spring-boot-starter-webmvcbrings Spring's web framework, Tomcat and Jackson. The generated Java code is one class:

src/main/java/com/example/library/LibraryApplication.java
read along
1package com.example.library;
2
3import org.springframework.boot.SpringApplication;
4import org.springframework.boot.autoconfigure.SpringBootApplication;
5
6@SpringBootApplication
7public class LibraryApplication {
8
9 public static void main(String[] args) {
10 SpringApplication.run(LibraryApplication.class, args);
11 }
12
13}

@SpringBootApplicationtells Spring to look through this class's package, and every package below it, for classes it should manage, and to set itself up from what it finds on the classpath. SpringApplication.run does all of that and starts the server. Run it from the project folder:

Terminal
read along
1$ mvn spring-boot:run
2...
3 :: Spring Boot :: (v4.1.1)
4
52026-10-19T15:54:52.311+03:00 INFO 59196 --- [library] [ main] com.example.library.LibraryApplication : Starting LibraryApplication using Java 25.0.4.1 with PID 59196 (...)
6...
72026-10-19T15:54:52.707+03:00 INFO 59196 --- [library] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
8...
92026-10-19T15:54:53.074+03:00 INFO 59196 --- [library] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
102026-10-19T15:54:53.078+03:00 INFO 59196 --- [library] [ main] com.example.library.LibraryApplication : Started LibraryApplication in 0.975 seconds (process running for 1.165)

The program does not end: it waits for requests on port 8080 until you press Ctrl+C. (The project also contains mvnw, the Maven wrapper from build-tools, so ./mvnw spring-boot:runworks on a computer without Maven.) There is nothing to ask for yet. Any address gets Spring's standard error answer:

Terminal (a second one)
read along
1$ curl http://localhost:8080/nothing-here
2{"timestamp":"2026-10-19T12:55:00.151Z","status":404,"error":"Not Found","path":"/nothing-here"}

A first endpoint#

A controller is a class whose methods answer requests. Add this file next to LibraryApplication.java, stop the program and start it again:

HelloController.java
read along
1package com.example.library;
2
3import org.springframework.web.bind.annotation.GetMapping;
4import org.springframework.web.bind.annotation.RequestParam;
5import org.springframework.web.bind.annotation.RestController;
6
7@RestController
8public class HelloController {
9 @GetMapping("/hello")
10 public String hello(@RequestParam(defaultValue = "world") String name) {
11 return "Hello, " + name + "!";
12 }
13}
Terminal
read along
1$ curl "http://localhost:8080/hello?name=Ada"
2Hello, Ada!
  • @RestController marks the class as a controller whose return values are the response body.
  • @GetMapping("/hello") says this method answers GET /hello.
  • @RequestParam fills the parameter name from ?name=Ada in the address. Without one, defaultValue gives world.

Notice what you did not write: no code to open a port, read the request, find the right method, or write the response. Spring does that, on a pool of threads, for every request.

A REST API for books#

Most web APIs follow a style called REST: each kind of thing has an address, such as /books, and the HTTP method says what to do with it.

RequestMeansAnswers with
GET /booksList all books200 and a JSON array
GET /books/2Get book 2200 and the book, or 404
POST /booksAdd a book (JSON in the body)201 and the new book with its id
PUT /books/2Replace book 2200 and the updated book, or 404
DELETE /books/2Remove book 2204 with no body, or 404

This lesson builds the first three, keeping the books in a list; the next lesson stores them in a database and adds the last two. A book is a record, and the data a client sends to add one is a second record:

Book.java and NewBook.java
read along
1public record Book(long id, String title, String author) {
2}
3
4public record NewBook(String title, String author) {
5}
BookService.java
read along
1package com.example.library;
2
3import java.util.ArrayList;
4import java.util.List;
5import java.util.Optional;
6import org.springframework.stereotype.Service;
7
8@Service
9public class BookService {
10 private final List<Book> books = new ArrayList<>();
11 private long nextId = 1;
12
13 public BookService() {
14 add("Dune", "Frank Herbert");
15 add("Emma", "Jane Austen");
16 }
17
18 public synchronized List<Book> all() {
19 return List.copyOf(books);
20 }
21
22 public synchronized Optional<Book> find(long id) {
23 return books.stream().filter(b -> b.id() == id).findFirst();
24 }
25
26 public synchronized Book add(String title, String author) {
27 Book book = new Book(nextId++, title, author);
28 books.add(book);
29 return book;
30 }
31}
BookController.java
read along
1package com.example.library;
2
3import java.util.List;
4import org.springframework.http.HttpStatus;
5import org.springframework.http.ResponseEntity;
6import org.springframework.web.bind.annotation.*;
7
8@RestController
9@RequestMapping("/books")
10public class BookController {
11 private final BookService service;
12
13 public BookController(BookService service) {
14 this.service = service;
15 }
16
17 @GetMapping
18 public List<Book> all() {
19 return service.all();
20 }
21
22 @GetMapping("/{id}")
23 public ResponseEntity<Book> one(@PathVariable long id) {
24 return service.find(id)
25 .map(ResponseEntity::ok)
26 .orElse(ResponseEntity.notFound().build());
27 }
28
29 @PostMapping
30 public ResponseEntity<Book> add(@RequestBody NewBook request) {
31 Book saved = service.add(request.title(), request.author());
32 return ResponseEntity.status(HttpStatus.CREATED).body(saved);
33 }
34}
  • @RequestMapping("/books") on the class puts /books in front of every method's path.
  • @PathVariable takes the {id} part of the address, so GET /books/2 calls one(2).
  • ResponseEntity lets a method choose the status: ResponseEntity.ok(book) for 200, notFound() for 404, status(HttpStatus.CREATED) for 201. A plain return value, as in all(), means 200.
  • @RequestBody reads the request's JSON body into a NewBook with Jackson, exactly as readValue did in the last lesson.
Terminal
read along
1$ curl http://localhost:8080/books
2[{"id":1,"title":"Dune","author":"Frank Herbert"},{"id":2,"title":"Emma","author":"Jane Austen"}]
3$ curl http://localhost:8080/books/2
4{"id":2,"title":"Emma","author":"Jane Austen"}
5$ curl -i http://localhost:8080/books/9
6HTTP/1.1 404
7Content-Length: 0
8Date: Mon, 19 Oct 2026 12:55:00 GMT
9
10$ curl -i -X POST http://localhost:8080/books -H "Content-Type: application/json" \
11 -d '{"title": "Solaris", "author": "Stanislaw Lem"}'
12HTTP/1.1 201
13Content-Type: application/json
14Transfer-Encoding: chunked
15Date: Mon, 19 Oct 2026 12:55:00 GMT
16
17{"id":3,"title":"Solaris","author":"Stanislaw Lem"}

The records come out as JSON with no work on your side. curl -i shows the status line and headers: a 404 with an empty body for a book that is not there, and a 201 Created with the new book, id 3 included, for the POST. -X POST sets the method, -H adds a header and -d sends the body. A GET /books now lists three books.

Dependency injection: who calls new?#

Look at BookController again. It needs a BookService, but it never writes new BookService(). It asks for one in its constructor, and nothing in your code calls that constructor either. Spring does. When the program starts, Spring finds the classes marked @Service and @RestController, creates one object of each (a bean), sees that the controller's constructor wants a BookService, and passes in the one it made. This is dependency injection: an object is given the objects it depends on, instead of making them itself.

Key idea

Ask for what you need in the constructor, and let Spring make the objects and hand them over. Each class then does one job and never decides where its helpers come from.

It pays off in two ways. Code is easy to rearrange: the next lesson swaps the list for a database, and the controller hardly changes. And code is easy to test: a test can create a BookController with a new and a fake service of its own, no Spring needed.

One BookService for every request
Spring makes one BookService and shares it with every request, and requests arrive on several threads at once. Two clients adding a book at the same moment would race on nextId and the list, exactly as in thread synchronization. That is why its methods are synchronized. A database, in the next lesson, handles this for you.

Settings in application.properties#

Spring Boot's settings live in src/main/resources/application.properties. The generated one has a single line, spring.application.name=library (that is the [library] in every log line). A few you will use often:

application.properties
read along
1spring.application.name=library
2server.port=9090
3logging.level.com.example.library=DEBUG

server.port moves the server off 8080, and logging.levelsets a package's log level, the same idea as Logback's per-package level in logging.

Quick check
A client sends GET /books/7, and no book has id 7. What should one(7) return?
Quick check
Which annotation reads the JSON a client sends in the body of a POST into a Java object?

Try it

Answer each one in your head or on paper first, then open the answer.

1
Write the controller method for GET /books/count, which answers with the number of books as plain text.
2
Why does BookController take a BookService in its constructor instead of creating one with new?
3
A POST to /books returns 201 with a body. What do 201 and the body tell the client that 200 alone would not?

Practice

Work through these on your own. Each one builds on this page.

1
Create the project with Spring Initializr and build the book API from this page. Add GET /books/search?title=..., which returns the books whose title contains the text, ignoring case, and test it with curl.
2
Make POST /books answer 400 Bad Request when the title or the author is empty, using ResponseEntity.badRequest().build(). Then read ahead: the next lesson does the same with one annotation.
3
Write a small Java program with HttpClient and Jackson that adds three books to your running API with POST and then prints the full list with GET.

Further reading#

Short guides on this topic

Quick answers to what people search for about it, with code to run.

Spotted a mistake, or a part that did not make sense? Report it by email