← All projects

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.

APIs & ServicesStarter4-6 hours#rest#crud#jpa#validation#pagination#testingSpring Boot 3Spring WebBean ValidationJPAFlywayPostgreSQLJUnit 5Testcontainers

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/     TaskRepositoryTest

Schema 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 the NOT NULL violation at night.
  • Calling save after merge-style updates and thinking that is what persists. save is for new or detached entities; inside a transaction, managed entities flush on commit. If your update only works with save, your transaction boundary is wrong.
  • Returning Page<Task> from the controller. Serializing entities drags in lazy associations and leaks schema. Map to TaskResponse inside the service, where the session is open.
  • Long id in PathVariable parsed after the 404 check. Bind Long id, not String id, so /api/tasks/abc becomes 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 @Transactional guarantee, 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 pushes LIMIT/OFFSET into SQL; the status filter becomes an index scan instead of a full table load.

Milestones

0 of 7 milestones

Done when

Stretch goals