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 BasithUpdated 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.
- 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.
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:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version></parent>...<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc-test</artifactId> <scope>test</scope> </dependency></dependencies>spring-boot-starter-webmvcbrings Spring's web framework, Tomcat and Jackson. The generated Java code is one class:
package com.example.library; import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplicationpublic class LibraryApplication { public static void main(String[] args) { SpringApplication.run(LibraryApplication.class, args); } }@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:
$ mvn spring-boot:run... :: Spring Boot :: (v4.1.1) 2026-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 (...)...2026-10-19T15:54:52.707+03:00 INFO 59196 --- [library] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)...2026-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 '/'2026-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:
$ curl http://localhost:8080/nothing-here{"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:
package com.example.library; import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController; @RestControllerpublic class HelloController { @GetMapping("/hello") public String hello(@RequestParam(defaultValue = "world") String name) { return "Hello, " + name + "!"; }}$ curl "http://localhost:8080/hello?name=Ada"Hello, Ada!@RestControllermarks the class as a controller whose return values are the response body.@GetMapping("/hello")says this method answersGET /hello.@RequestParamfills the parameternamefrom?name=Adain the address. Without one,defaultValuegivesworld.
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.
| Request | Means | Answers with |
|---|---|---|
GET /books | List all books | 200 and a JSON array |
GET /books/2 | Get book 2 | 200 and the book, or 404 |
POST /books | Add a book (JSON in the body) | 201 and the new book with its id |
PUT /books/2 | Replace book 2 | 200 and the updated book, or 404 |
DELETE /books/2 | Remove book 2 | 204 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:
public record Book(long id, String title, String author) {} public record NewBook(String title, String author) {}package com.example.library; import java.util.ArrayList;import java.util.List;import java.util.Optional;import org.springframework.stereotype.Service; @Servicepublic class BookService { private final List<Book> books = new ArrayList<>(); private long nextId = 1; public BookService() { add("Dune", "Frank Herbert"); add("Emma", "Jane Austen"); } public synchronized List<Book> all() { return List.copyOf(books); } public synchronized Optional<Book> find(long id) { return books.stream().filter(b -> b.id() == id).findFirst(); } public synchronized Book add(String title, String author) { Book book = new Book(nextId++, title, author); books.add(book); return book; }}package com.example.library; import java.util.List;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*; @RestController@RequestMapping("/books")public class BookController { private final BookService service; public BookController(BookService service) { this.service = service; } @GetMapping public List<Book> all() { return service.all(); } @GetMapping("/{id}") public ResponseEntity<Book> one(@PathVariable long id) { return service.find(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PostMapping public ResponseEntity<Book> add(@RequestBody NewBook request) { Book saved = service.add(request.title(), request.author()); return ResponseEntity.status(HttpStatus.CREATED).body(saved); }}@RequestMapping("/books")on the class puts/booksin front of every method's path.@PathVariabletakes the{id}part of the address, soGET /books/2callsone(2).ResponseEntitylets a method choose the status:ResponseEntity.ok(book)for 200,notFound()for 404,status(HttpStatus.CREATED)for 201. A plain return value, as inall(), means 200.@RequestBodyreads the request's JSON body into aNewBookwith Jackson, exactly asreadValuedid in the last lesson.
$ curl http://localhost:8080/books[{"id":1,"title":"Dune","author":"Frank Herbert"},{"id":2,"title":"Emma","author":"Jane Austen"}]$ curl http://localhost:8080/books/2{"id":2,"title":"Emma","author":"Jane Austen"}$ curl -i http://localhost:8080/books/9HTTP/1.1 404Content-Length: 0Date: Mon, 19 Oct 2026 12:55:00 GMT $ curl -i -X POST http://localhost:8080/books -H "Content-Type: application/json" \ -d '{"title": "Solaris", "author": "Stanislaw Lem"}'HTTP/1.1 201Content-Type: application/jsonTransfer-Encoding: chunkedDate: Mon, 19 Oct 2026 12:55:00 GMT {"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 ideaAsk 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.
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:
spring.application.name=libraryserver.port=9090logging.level.com.example.library=DEBUGserver.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.
GET /books/7, and no book has id 7. What should one(7) return?Try it
Answer each one in your head or on paper first, then open the answer.
GET /books/count, which answers with the number of books as plain text.BookController take a BookService in its constructor instead of creating one with new?/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.
GET /books/search?title=..., which returns the books whose title contains the text, ignoring case, and test it with curl.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.HttpClient and Jackson that adds three books to your running API with POST and then prints the full list with GET.Further reading#
- Building a RESTful Web Service, a Spring getting-started guide
- The Spring Boot reference documentation
- Spring Initializr
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