Learn Java

Lesson 75Phase 9: Data and the web

Capstone: build and ship a small web API

A guided final project: design, build, test and deploy a task-tracking API, using everything from the course.

By Updated 90 min

  • project
  • design
  • REST API
  • testing
  • persistence
  • deployment

This is the last project, and it uses nearly everything in the course. You will design, build, test and ship a small web API for keeping track of tasks: the kind of service a to-do app on a phone would talk to. The steps are laid out, and the core code is given so you can check yourself, but the point is to build it with your own hands, run it, break it, and make it yours.

By the end of this lesson
  • Design an API before writing it: the resource, its fields, the endpoints, the status codes and the rules.
  • Build it with Spring Boot, Spring Data JPA and validation, in small steps that each run.
  • Prove it works with automated tests that send real requests through Spring.
  • Ship it as a container, with a database beside it and every push built and tested by CI.
From before
This project is the last four lessons in one piece: HTTP and JSON, Spring Boot, Spring Data JPA and Docker and GitHub Actions, with testing from JUnit and version control from Git. The design habits come from the console app project.

The brief#

A task has a title, an optional due date, and a flag that says whether it is done. Clients must be able to list tasks (all of them, or only the open ones), read one, create one, update one and delete one. A title is required and at most 100 characters. Asking for a task that does not exist is a 404, and breaking a rule is a 400.

Step 1: design on paper#

Before any code, write down the contract a client will rely on:

RequestBodyAnswer
GET /tasksnone200, all tasks
GET /tasks?done=falsenone200, only the open tasks
GET /tasks/7none200 and the task, or 404
POST /tasks{"title": ..., "dueDate": ...}201 and the new task, or 400
PUT /tasks/7{"title": ..., "dueDate": ..., "done": ...}200 and the task, 400, or 404
DELETE /tasks/7none204, or 404

Dates travel as text in the ISO format, 2026-11-01, which Jackson reads straight into a LocalDate, from dates and times. Decide now what the JSON for one task looks like: {"id": 1, "title": "Write the tests", "dueDate": "2026-11-01", "done": false}.

Step 2: build it, one running piece at a time#

  1. 1Create the project at start.spring.io with Maven, Java 25 and the dependencies Spring Web, Spring Data JPA, Validation and H2 Database. Run it once, untouched, then git init and make your first commit.
  2. 2Write the entity and the repository, and check with spring.jpa.show-sql=true that Hibernate creates the task table at start-up.
  3. 3Write the request record and the controller. After each endpoint, start the app and try it with curl before writing the next.
  4. 4Write the tests and run ./mvnw test. Commit whenever everything passes.
Task.java
read along
1package com.example.tasks;
2
3import jakarta.persistence.Entity;
4import jakarta.persistence.GeneratedValue;
5import jakarta.persistence.Id;
6import java.time.LocalDate;
7
8@Entity
9public class Task {
10 @Id
11 @GeneratedValue
12 private Long id;
13 private String title;
14 private LocalDate dueDate;
15 private boolean done;
16
17 protected Task() {
18 }
19
20 public Task(String title, LocalDate dueDate) {
21 this.title = title;
22 this.dueDate = dueDate;
23 }
24
25 public Long getId() { return id; }
26 public String getTitle() { return title; }
27 public LocalDate getDueDate() { return dueDate; }
28 public boolean isDone() { return done; }
29 public void setTitle(String title) { this.title = title; }
30 public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
31 public void setDone(boolean done) { this.done = done; }
32}
TaskRepository.java and TaskRequest.java
read along
1public interface TaskRepository extends JpaRepository<Task, Long> {
2 List<Task> findByDone(boolean done);
3}
4
5public record TaskRequest(@NotBlank @Size(max = 100) String title, LocalDate dueDate, Boolean done) {
6}
Why Boolean and not boolean in the request
A client creating a task sends no done at all. With a primitive boolean, Spring Boot's JSON settings reject the request with a 400 and the message Cannot map `null` into type `boolean`, which is exactly what the first test run of this project did. The wrapper Boolean can be null, meaning “not sent”, and the controller treats that as false. (Plain Jackson, as in the HttpClient lesson, quietly uses false instead; frameworks can set Jackson up more strictly.)
TaskController.java (the methods)
read along
1@RestController
2@RequestMapping("/tasks")
3public class TaskController {
4 private final TaskRepository tasks;
5
6 public TaskController(TaskRepository tasks) {
7 this.tasks = tasks;
8 }
9
10 @GetMapping
11 public List<Task> all(@RequestParam(required = false) Boolean done) {
12 return done == null ? tasks.findAll() : tasks.findByDone(done);
13 }
14
15 @GetMapping("/{id}")
16 public ResponseEntity<Task> one(@PathVariable long id) {
17 return tasks.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
18 }
19
20 @PostMapping
21 public ResponseEntity<Task> create(@Valid @RequestBody TaskRequest request) {
22 Task task = new Task(request.title(), request.dueDate());
23 return ResponseEntity.status(HttpStatus.CREATED).body(tasks.save(task));
24 }
25
26 @PutMapping("/{id}")
27 public ResponseEntity<Task> update(@PathVariable long id, @Valid @RequestBody TaskRequest request) {
28 return tasks.findById(id)
29 .map(task -> {
30 task.setTitle(request.title());
31 task.setDueDate(request.dueDate());
32 task.setDone(Boolean.TRUE.equals(request.done()));
33 return ResponseEntity.ok(tasks.save(task));
34 })
35 .orElse(ResponseEntity.notFound().build());
36 }
37
38 @DeleteMapping("/{id}")
39 public ResponseEntity<Void> delete(@PathVariable long id) {
40 if (!tasks.existsById(id)) {
41 return ResponseEntity.notFound().build();
42 }
43 tasks.deleteById(id);
44 return ResponseEntity.noContent().build();
45 }
46}

Step 3: tests that send real requests#

Trying endpoints with curl is good while you build. To know they keep working after every change, automate it. @SpringBootTest starts the whole application inside a test, and MockMvc sends requests to it without a real network, then checks the answers:

src/test/java/com/example/tasks/TaskApiTests.java
read along
1package com.example.tasks;
2
3import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
4import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
5import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
6import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
7
8import org.junit.jupiter.api.Test;
9import org.springframework.beans.factory.annotation.Autowired;
10import org.springframework.boot.test.context.SpringBootTest;
11import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
12import org.springframework.http.MediaType;
13import org.springframework.test.web.servlet.MockMvc;
14
15@SpringBootTest
16@AutoConfigureMockMvc
17class TaskApiTests {
18 @Autowired
19 MockMvc mvc;
20
21 @Test
22 void createsATask() throws Exception {
23 mvc.perform(post("/tasks")
24 .contentType(MediaType.APPLICATION_JSON)
25 .content("""
26 {"title": "Write the tests", "dueDate": "2026-11-01"}
27 """))
28 .andExpect(status().isCreated())
29 .andExpect(jsonPath("$.title").value("Write the tests"))
30 .andExpect(jsonPath("$.done").value(false));
31 }
32
33 @Test
34 void refusesABlankTitle() throws Exception {
35 mvc.perform(post("/tasks")
36 .contentType(MediaType.APPLICATION_JSON)
37 .content("""
38 {"title": ""}
39 """))
40 .andExpect(status().isBadRequest());
41 }
42
43 @Test
44 void answers404ForAMissingTask() throws Exception {
45 mvc.perform(get("/tasks/999"))
46 .andExpect(status().isNotFound());
47 }
48}
Terminal
read along
1$ ./mvnw test
2...
3[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
4[INFO] BUILD SUCCESS
  • @Autowired on the field asks Spring to inject the MockMvc, the same idea as constructor injection, written the short way that tests usually use.
  • perform(post(...)) builds and sends a request; each andExpect checks one thing about the answer.
  • jsonPath("$.title") picks a value out of the JSON body: $ is the whole body and .title its title field.
  • Test the rules and the failures, not only the happy path: the 400 and the 404 are part of the contract too.
Key idea

The tests are the contract from Step 1, written in Java. When they pass on every push, you can change the code without fear.

Step 4: ship it#

Reuse the files from Docker and GitHub Actions with the names changed: the two-stage Dockerfile (copying tasks-0.0.1-SNAPSHOT.jar), a compose.yaml with PostgreSQL and the app, the PostgreSQL driver in pom.xml, and the workflow in .github/workflows/ci.yml. Push to GitHub and watch the first run go green. At that point anyone can clone the repository and start the whole system with docker compose up --build.

Step 5: review it like a colleague would#

  • Does every endpoint in the design table exist, with the status codes the table promised?
  • Is there a test for each rule and each failure, and do they all pass in CI?
  • Are there passwords or other secrets anywhere in the repository? There should be none.
  • Could a stranger run it from the README alone? Write one: what it is, how to run it, how to test it, an example request.
  • Is each class doing one job, as in design principles?

Make it yours: extensions#

ExtensionUses
Clear error messages: a 400 that says which field broke which rule@RestControllerAdvice and @ExceptionHandler
Overdue tasks: GET /tasks/overdueA derived query such as findByDoneFalseAndDueDateBefore(LocalDate date)
Pages of results for long listsPageable parameters and Page<Task> results from Spring Data
Logging each create and deleteA logger, as in logging
Users who see only their own tasksSpring Security, the next thing to learn after this course
A client for itA console program with HttpClient, or a JavaFX window from the desktop track

Where you have been#

From an empty file to a tested, containerised web service: variables and loops in Phase 1, objects in Phase 2, abstraction and design in Phase 3, collections and algorithms in Phase 4, errors, files and the standard library in Phase 5, functional and modern Java in Phase 6 with the console app project, concurrency in Phase 7, the tools of the trade in Phase 8, and data and the web in Phase 9, ending here. Every one of those ideas is somewhere in this project, from the record you send to the thread pool that answers it.

What next
Keep building. Pick something you would use yourself, a reading log, a budget, a club's schedule, design its API on paper, and build it the same way: small steps that each run, tests for every rule, and CI from the first commit. The glossary and the cheat sheets are there when you need a reminder.

Try it

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

1
Write the derived query and the controller method for GET /tasks/overdue: open tasks whose due date is before today.
2
Write a test that creates a task, marks it done with PUT, and checks that GET /tasks?done=false no longer lists it.

Practice

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

1
Build the project from this page, with the tests, the Dockerfile, the Compose file and the CI workflow, in a GitHub repository with a README. Then add two extensions from the table, each with its own tests.
2
Ask a friend to clone your repository and run it from the README without your help. Write down every question they had to ask you, and fix the README until there are none.

Further reading#

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