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 BasithUpdated 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.
- 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.
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:
| Request | Body | Answer |
|---|---|---|
GET /tasks | none | 200, all tasks |
GET /tasks?done=false | none | 200, only the open tasks |
GET /tasks/7 | none | 200 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/7 | none | 204, 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#
- 1Create the project at
start.spring.iowith Maven, Java 25 and the dependencies Spring Web, Spring Data JPA, Validation and H2 Database. Run it once, untouched, thengit initand make your first commit. - 2Write the entity and the repository, and check with
spring.jpa.show-sql=truethat Hibernate creates thetasktable at start-up. - 3Write the request record and the controller. After each endpoint, start the app and try it with curl before writing the next.
- 4Write the tests and run
./mvnw test. Commit whenever everything passes.
package com.example.tasks; import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.Id;import java.time.LocalDate; @Entitypublic class Task { @Id @GeneratedValue private Long id; private String title; private LocalDate dueDate; private boolean done; protected Task() { } public Task(String title, LocalDate dueDate) { this.title = title; this.dueDate = dueDate; } public Long getId() { return id; } public String getTitle() { return title; } public LocalDate getDueDate() { return dueDate; } public boolean isDone() { return done; } public void setTitle(String title) { this.title = title; } public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; } public void setDone(boolean done) { this.done = done; }}public interface TaskRepository extends JpaRepository<Task, Long> { List<Task> findByDone(boolean done);} public record TaskRequest(@NotBlank @Size(max = 100) String title, LocalDate dueDate, Boolean done) {}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.)@RestController@RequestMapping("/tasks")public class TaskController { private final TaskRepository tasks; public TaskController(TaskRepository tasks) { this.tasks = tasks; } @GetMapping public List<Task> all(@RequestParam(required = false) Boolean done) { return done == null ? tasks.findAll() : tasks.findByDone(done); } @GetMapping("/{id}") public ResponseEntity<Task> one(@PathVariable long id) { return tasks.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); } @PostMapping public ResponseEntity<Task> create(@Valid @RequestBody TaskRequest request) { Task task = new Task(request.title(), request.dueDate()); return ResponseEntity.status(HttpStatus.CREATED).body(tasks.save(task)); } @PutMapping("/{id}") public ResponseEntity<Task> update(@PathVariable long id, @Valid @RequestBody TaskRequest request) { return tasks.findById(id) .map(task -> { task.setTitle(request.title()); task.setDueDate(request.dueDate()); task.setDone(Boolean.TRUE.equals(request.done())); return ResponseEntity.ok(tasks.save(task)); }) .orElse(ResponseEntity.notFound().build()); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable long id) { if (!tasks.existsById(id)) { return ResponseEntity.notFound().build(); } tasks.deleteById(id); return ResponseEntity.noContent().build(); }}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:
package com.example.tasks; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;import org.springframework.http.MediaType;import org.springframework.test.web.servlet.MockMvc; @SpringBootTest@AutoConfigureMockMvcclass TaskApiTests { @Autowired MockMvc mvc; @Test void createsATask() throws Exception { mvc.perform(post("/tasks") .contentType(MediaType.APPLICATION_JSON) .content(""" {"title": "Write the tests", "dueDate": "2026-11-01"} """)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.title").value("Write the tests")) .andExpect(jsonPath("$.done").value(false)); } @Test void refusesABlankTitle() throws Exception { mvc.perform(post("/tasks") .contentType(MediaType.APPLICATION_JSON) .content(""" {"title": ""} """)) .andExpect(status().isBadRequest()); } @Test void answers404ForAMissingTask() throws Exception { mvc.perform(get("/tasks/999")) .andExpect(status().isNotFound()); }}$ ./mvnw test...[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0[INFO] BUILD SUCCESS@Autowiredon the field asks Spring to inject theMockMvc, the same idea as constructor injection, written the short way that tests usually use.perform(post(...))builds and sends a request; eachandExpectchecks one thing about the answer.jsonPath("$.title")picks a value out of the JSON body:$is the whole body and.titleitstitlefield.- Test the rules and the failures, not only the happy path: the 400 and the 404 are part of the contract too.
Key ideaThe 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#
| Extension | Uses |
|---|---|
| Clear error messages: a 400 that says which field broke which rule | @RestControllerAdvice and @ExceptionHandler |
Overdue tasks: GET /tasks/overdue | A derived query such as findByDoneFalseAndDueDateBefore(LocalDate date) |
| Pages of results for long lists | Pageable parameters and Page<Task> results from Spring Data |
| Logging each create and delete | A logger, as in logging |
| Users who see only their own tasks | Spring Security, the next thing to learn after this course |
| A client for it | A 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.
Try it
Answer each one in your head or on paper first, then open the answer.
GET /tasks/overdue: open tasks whose due date is before today.GET /tasks?done=false no longer lists it.Practice
Work through these on your own. Each one builds on this page.
Further reading#
Spotted a mistake, or a part that did not make sense? Report it by email