Task Manager REST API
Build your first Spring Boot service end to end — CRUD for tasks with a DTO layer, request validation, pagination, a status state machine, ProblemDetail errors and one Testcontainers test.
Revise first: Spring Core, DI & Bean LifecycleREST API Design, Validation & ControllerAdviceHibernate & JPA InternalsJava Testing (JUnit, Mockito)
You will build a small task API: create tasks, list them with filters and pagination, move them through TODO -> IN_PROGRESS -> DONE, and delete them. The domain is deliberately boring so the learning is in the habits every later project depends on: entities never leave the service layer, validation happens at the edge, transactions live on service methods, and errors come back in one consistent shape. Get these four right here and every bigger project in this kit becomes easier.
Project layout
src/main/java/com/example/tasks/
api/ TaskController, CreateTaskRequest, TaskResponse, ApiExceptionHandler
domain/ Task, TaskStatus, TaskRepository
service/ TaskService, TaskNotFoundException, InvalidTransitionException
src/main/resources/
application.yml
db/migration/V1__create_task.sql
src/test/java/com/example/tasks/
api/ TaskIT
domain/ TaskRepositoryTestSchema and entity
Flyway owns the schema; JPA only maps it. A CHECK constraint keeps invalid statuses out of the database even if a bug skips the enum, and the index serves the status filter.
CREATE TABLE task (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(120) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'TODO',
due_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT task_status_chk CHECK (status IN ('TODO', 'IN_PROGRESS', 'DONE'))
);
CREATE INDEX task_status_idx ON task (status);public enum TaskStatus {
TODO, IN_PROGRESS, DONE;
public boolean canMoveTo(TaskStatus to) {
return switch (this) {
case TODO -> to == IN_PROGRESS;
case IN_PROGRESS -> to == DONE || to == TODO;
case DONE -> false;
};
}
}The state machine lives on the enum, not in an if ladder in the service: the rules are in one place and unit-testable without Spring.
@Entity
public class Task {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String title;
@Enumerated(EnumType.STRING)
private TaskStatus status = TaskStatus.TODO;
private LocalDate dueDate;
private Instant createdAt = Instant.now();
// getters and setters omitted
}EnumType.STRING, never ORDINAL: adding a status later would silently renumber every stored row.
DTOs and validation
The API speaks records, not entities. Exposing Task directly couples your JSON to your schema forever and lets clients set id or status in the create payload (mass assignment). Validation annotations on the request record are checked by @Valid at the controller edge.
public record CreateTaskRequest(
@NotBlank @Size(max = 120) String title,
@Future LocalDate dueDate) {}
public record TaskResponse(Long id, String title, TaskStatus status,
LocalDate dueDate, Instant createdAt) {
static TaskResponse from(Task t) {
return new TaskResponse(t.getId(), t.getTitle(), t.getStatus(),
t.getDueDate(), t.getCreatedAt());
}
}The service layer and transactions
Constructor injection, no @Autowired on fields: one constructor means Spring wires it anyway, the field can be final, and unit tests just call new TaskService(fakeRepo).
@Service
public class TaskService {
private final TaskRepository repo;
TaskService(TaskRepository repo) { this.repo = repo; }
@Transactional
public TaskResponse create(CreateTaskRequest req) {
Task t = new Task();
t.setTitle(req.title());
t.setDueDate(req.dueDate());
return TaskResponse.from(repo.save(t));
}
@Transactional
public TaskResponse move(Long id, TaskStatus to) {
Task t = repo.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
if (!t.getStatus().canMoveTo(to))
throw new InvalidTransitionException(t.getStatus(), to);
t.setStatus(to);
return TaskResponse.from(t); // no save(): dirty checking flushes on commit
}
}move deliberately calls no save. Inside a transaction, Hibernate writes back changed managed entities at commit — that is what @Transactional actually buys you. Reads get @Transactional(readOnly = true): no dirty-checking snapshots, and the JDBC driver can route to a replica.
@Transactional(readOnly = true)
public Page<TaskResponse> list(TaskStatus status, Pageable page) {
Page<Task> result = status == null ? repo.findAll(page) : repo.findByStatus(status, page);
return result.map(TaskResponse::from);
}The controller
Status codes are part of the contract: POST returns 201 with a Location header, PATCH returns 200, DELETE returns 204 and no body.
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService service;
TaskController(TaskService service) { this.service = service; }
@PostMapping
public ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest req) {
TaskResponse body = service.create(req);
return ResponseEntity.created(URI.create("/api/tasks/" + body.id())).body(body);
}
@GetMapping
public Page<TaskResponse> list(@RequestParam(required = false) TaskStatus status,
@PageableDefault(size = 20, sort = "createdAt") Pageable page) {
return service.list(status, page);
}
}Pageable binds ?page=0&size=20&sort=createdAt,desc for free and the SQL gets a real LIMIT/OFFSET. Cap size (spring.data.web.pageable.max-page-size: 100) or one request can ask for the whole table.
Errors in one shape
Every failure returns the same RFC 9457 problem document, produced in one place. Handlers for TaskNotFoundException (404) and InvalidTransitionException (409) are two more one-liners of the same shape.
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail onValidation(MethodArgumentNotValidException e) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + " " + f.getDefaultMessage()).toList());
return pd;
}
}Testing
One repository test with @DataJpaTest (Hibernate only, fast) and one end-to-end test with Testcontainers against real PostgreSQL — the same setup the URL Shortener project uses. Testing against H2 hides that your CHECK constraint and index only exist in Postgres.
@SpringBootTest
@Testcontainers
class TaskIT {
@Container @ServiceConnection
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
@Autowired TestRestTemplate http;
@Test
void createMoveRejectWrongTransition() {
var created = http.postForEntity("/api/tasks", Map.of("title", "write tests"), Map.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
String id = created.getBody().get("id").toString();
http.patchForObject("/api/tasks/" + id + "/status", Map.of("status", "IN_PROGRESS"), Map.class);
var sameStatus = http.exchange("/api/tasks/" + id + "/status", HttpMethod.PATCH,
new HttpEntity<>(Map.of("status", "IN_PROGRESS")), Map.class);
assertThat(sameStatus.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
var blank = http.postForEntity("/api/tasks", Map.of("title", " "), Map.class);
assertThat(blank.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}@ServiceConnection derives the JDBC URL, user and password from the container, so application.yml needs no test profile.
Bugs you will hit
- Forgetting
@Valid. The annotations on the record do nothing until something triggers validation; a blank title sails through as a 200 and you only notice theNOT NULLviolation at night. - Calling
saveaftermerge-style updates and thinking that is what persists.saveis for new or detached entities; inside a transaction, managed entities flush on commit. If your update only works withsave, your transaction boundary is wrong. - Returning
Page<Task>from the controller. Serializing entities drags in lazy associations and leaks schema. Map toTaskResponseinside the service, where the session is open. Longid inPathVariableparsed after the 404 check. BindLong id, notString id, so/api/tasks/abcbecomes a 400 from Spring, not your exception handler's NPE.
Interview angle
- Why a DTO layer for a five-field entity? API/schema decoupling, no mass assignment, no lazy serialization surprises. The cost is a mapper method; the benefit is that adding a column does not change your contract.
- What does
@Transactionalguarantee, and where does it go? One unit of work per business operation, on the service method. Controller-level transactions keep DB connections open for the whole request; repository-level ones make multi-write operations non-atomic. - 400 or 409? 400 means the request is malformed (validation); 409 means it conflicts with the current state of the resource (
DONE -> TODO). The RFC wording is "conflict with the current state of the target resource". - What happens to dirty checking with
readOnly = true? Hibernate skips snapshots, saving memory and CPU on queries; you also signal the driver it may route to a replica. It is an optimization with correctness unchanged. - Why not
findAll()and filter in Java? The table outgrows memory. Pagination pushesLIMIT/OFFSETinto SQL; the status filter becomes an index scan instead of a full table load.
Milestones
Done when
- A valid
POSTreturns 201, aLocationheader and the created body; a blank title returns 400 naming the field. - Listing honors
page,size,sortandstatus, and the SQL log shows server-side pagination. - Moving a task to
DONEand then anywhere else returns 409 in the same problem-document shape as the 400 and 404. - No endpoint accepts or returns anything except the DTO records;
Taskappears in no signature underapi/. - The integration test passes against real PostgreSQL from a clean checkout.
Stretch goals
- Add
@Versionoptimistic locking and a test where two concurrentmovecalls make exactly one win with a 409. - Add
dueDatereminders: a@Scheduledscan that logs tasks due tomorrow, with the clock injected so the test does not sleep. - Add
springdoc-openapiand confirm the ProblemDetail responses show up in the schema. - Replace hard delete with a soft delete (
deleted_at) and prove the list filter excludes deleted rows.