Project — A Task Tracker REST API¶
Time to put Level 1 together. You will build a small but properly structured REST API for tasks: create, list with a filter, read, change status, delete. It validates input, returns standard problem-detail errors, reads a limit from typed configuration, logs meaningful events, and has controller tests.
Everything on this page was built and run against Spring Boot 4.1.1 while writing the course; the requests and responses shown are the real ones (only the port was changed from 8089 to the default 8080 in the commands).
Requirements¶
| Endpoint | Behavior |
|---|---|
POST /api/tasks |
Create with title (required, ≤120 chars) and optional description (≤2000). 201 + Location. 409 when the open-task limit is reached. |
GET /api/tasks?status= |
List all tasks, optionally filtered by status, ordered by id. |
GET /api/tasks/{id} |
One task or 404. |
PATCH /api/tasks/{id}/status |
Body {"status":"DONE"}. 400 if missing/invalid. |
DELETE /api/tasks/{id} |
204, or 404 if absent. |
Non-functional: errors are application/problem+json; the limit comes from
tasks.max-open-tasks and is validated at startup; each creation is logged.
Setup¶
curl -s -o tasks.zip "https://start.spring.io/starter.zip?type=maven-project&language=java&javaVersion=17&groupId=com.example&artifactId=tasks&packageName=com.example.tasks&dependencies=web,validation"
unzip tasks.zip -d tasks && cd tasks
Package layout:
com.example.tasks
├── TasksApplication.java
└── task
├── Task.java CreateTaskRequest.java UpdateStatusRequest.java
├── TaskService.java TaskController.java ApiExceptionHandler.java
├── TaskProperties.java TaskNotFoundException.java
└── TooManyOpenTasksException.java
The domain¶
package com.example.tasks.task;
import java.time.Instant;
public record Task(long id, String title, String description, Status status, Instant createdAt) {
public enum Status { OPEN, IN_PROGRESS, DONE }
Task withStatus(Status next) {
return new Task(id, title, description, next, createdAt);
}
}
An immutable record: changing status produces a new value. That makes the concurrent in-memory store below safe without locking individual tasks.
Configuration¶
@Validated
@ConfigurationProperties(prefix = "tasks")
public record TaskProperties(@Min(1) @Max(10_000) int maxOpenTasks) { }
@SpringBootApplication
@ConfigurationPropertiesScan
public class TasksApplication {
public static void main(String[] args) {
SpringApplication.run(TasksApplication.class, args);
}
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
The Clock bean exists so tests can control time.
The service¶
@Service
public class TaskService {
private static final Logger log = LoggerFactory.getLogger(TaskService.class);
private final Map<Long, Task> tasks = new ConcurrentHashMap<>();
private final AtomicLong ids = new AtomicLong();
private final TaskProperties properties;
private final Clock clock;
public TaskService(TaskProperties properties, Clock clock) {
this.properties = properties;
this.clock = clock;
}
public List<Task> findAll(Task.Status status) {
return tasks.values().stream()
.filter(t -> status == null || t.status() == status)
.sorted(Comparator.comparingLong(Task::id))
.toList();
}
public Task get(long id) {
Task task = tasks.get(id);
if (task == null) {
throw new TaskNotFoundException(id);
}
return task;
}
public Task create(CreateTaskRequest request) {
long open = tasks.values().stream().filter(t -> t.status() != Task.Status.DONE).count();
if (open >= properties.maxOpenTasks()) {
throw new TooManyOpenTasksException(properties.maxOpenTasks());
}
long id = ids.incrementAndGet();
Task task = new Task(id, request.title().strip(), request.description(),
Task.Status.OPEN, Instant.now(clock));
tasks.put(id, task);
log.info("Created task id={} title={}", id, task.title());
return task;
}
public Task updateStatus(long id, Task.Status status) {
Task updated = get(id).withStatus(status);
tasks.put(id, updated);
return updated;
}
public void delete(long id) {
if (tasks.remove(id) == null) {
throw new TaskNotFoundException(id);
}
}
}
This is a singleton shared by every request thread, so its mutable state is in a
ConcurrentHashMap and an AtomicLong. The limit check and insert are not atomic
together — two concurrent creates at the limit could both pass. For an in-memory demo
that is acceptable and documented here; in Level 2 the database enforces invariants
inside a transaction.
The web layer¶
The request DTOs, controller, and exception handler are exactly the ones developed in lessons 06–08:
public record CreateTaskRequest(@NotBlank @Size(max = 120) String title,
@Size(max = 2000) String description) { }
public record UpdateStatusRequest(@NotNull Task.Status status) { }
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) { this.service = service; }
@GetMapping
public List<Task> list(@RequestParam(required = false) Task.Status status) {
return service.findAll(status);
}
@GetMapping("/{id}")
public Task get(@PathVariable long id) { return service.get(id); }
@PostMapping
public ResponseEntity<Task> create(@Valid @RequestBody CreateTaskRequest request) {
Task task = service.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(task.id()).toUri();
return ResponseEntity.created(location).body(task);
}
@PatchMapping("/{id}/status")
public Task updateStatus(@PathVariable long id, @Valid @RequestBody UpdateStatusRequest request) {
return service.updateStatus(id, request.status());
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) { service.delete(id); }
}
ApiExceptionHandler maps TaskNotFoundException → 404, TooManyOpenTasksException →
409, and adds an errors map to validation failures (see lesson 08 for the full class).
This project returns the Task record directly because it is the API model; there is no
separate persistence model yet. Once JPA entities arrive in Level 2 you will introduce
response DTOs.
Tests¶
@WebMvcTest(TaskController.class)
class TaskControllerTest {
@Autowired MockMvc mvc;
@MockitoBean TaskService service;
@Test
void missingTaskIsA404ProblemDetail() throws Exception {
given(service.get(42)).willThrow(new TaskNotFoundException(42));
mvc.perform(get("/api/tasks/42"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Task not found"))
.andExpect(jsonPath("$.detail").value("Task 42 does not exist"));
}
@Test
void blankTitleIsRejected() throws Exception {
mvc.perform(post("/api/tasks").contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\" \"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.title").exists());
}
}
In Boot 4, @WebMvcTest is imported from org.springframework.boot.webmvc.test.autoconfigure
(in Boot 3 it was org.springframework.boot.test.autoconfigure.web.servlet), and
@MockitoBean comes from org.springframework.test.context.bean.override.mockito — it
replaced Boot's older @MockBean, which was deprecated in 3.4 and removed in 4.0.
./mvnw test ran these two tests plus the generated context-load test: Tests run: 3,
Failures: 0, Errors: 0, Skipped: 0.
Running it¶
$ curl -i -X POST localhost:8080/api/tasks -H 'Content-Type: application/json' \
-d '{"title":"Write the README","description":"cover setup"}'
HTTP/1.1 201
Location: http://localhost:8080/api/tasks/1
Content-Type: application/json
{"id":1,"title":"Write the README","description":"cover setup","status":"OPEN","createdAt":"2026-09-26T15:35:18.737961Z"}
$ curl localhost:8080/api/tasks/99
{"detail":"Task 99 does not exist","instance":"/api/tasks/99","status":404,"title":"Task not found"}
$ curl -X PATCH localhost:8080/api/tasks/1/status -H 'Content-Type: application/json' -d '{"status":"DONE"}'
{"id":1,"title":"Write the README","description":"cover setup","status":"DONE","createdAt":"2026-09-26T15:35:18.737961Z"}
The app started in about a second (Started TasksApplication in 1.014 seconds on the
machine used for this course; yours will differ).
How It Actually Works¶
Trace one POST /api/tasks through everything you have learned:
- Tomcat accepts the connection and assigns a worker thread (
nio-8080-exec-N). - The filter chain runs (character encoding, and your
RequestIdFilterif you added it). DispatcherServletasksRequestMappingHandlerMapping, which matchesPOST /api/taskstoTaskController.create.- The
@RequestBodyresolver reads JSON with Jackson intoCreateTaskRequest, then@Validruns Hibernate Validator. A blank title throwsMethodArgumentNotValidExceptionhere — the controller is never called. createcalls the singletonTaskService— the same instance the container injected at startup, now shared with every other request thread.- The service checks the limit from
TaskProperties, which was bound and validated once at startup. - The returned
ResponseEntitybecomes a 201 with aLocationheader; Jackson writes theTaskrecord, withInstantas an ISO string. - Had any step thrown,
ExceptionHandlerExceptionResolverwould have routed the exception toApiExceptionHandler, producing a problem-detail body.
Common mistakes to check for in your version¶
- Controllers doing filtering or limit checks that belong in the service.
- Missing
@Valid, so blank titles are accepted. - Mutable
ArrayList/HashMapstate in the singleton service. - Tests that use
@SpringBootTestfor everything; the web slice here starts much faster and tests exactly the HTTP contract.
Exercise (stretch goals)¶
- Add
priority(1–5) anddueDate(@FutureOrPresent), and aGET /api/tasks/overdueendpoint that uses the injectedClock. Unit-test the overdue logic withClock.fixed(...). - Add a
TaskServiceTest(no Spring) covering the limit, including the boundary: withmaxOpenTasks = 2, two open tasks succeed, the third fails, and completing one frees a slot. - Add the
RequestIdFilterfrom lesson 09 and assert in a@WebMvcTestthat theX-Request-Idheader is echoed back. - Make the limit check and insert atomic (for example with
synchronizedorConcurrentHashMap.compute-style logic), and write a concurrent test that proves the limit can no longer be exceeded.