LearnAI ToolsCareerPractice BuildsPlayContact
Spring BootBeginner~1.5 hours

Task Manager REST API

Build a full CRUD API for tasks, backed by Spring Data JPA.

REST ControllersSpring Data JPAValidation

Overview

A task manager API is the standard first project for seeing what Spring Boot and Spring Data JPA actually save you from writing. Without them, a CRUD endpoint means a servlet, a hand-wired JDBC connection, and a `PreparedStatement` for every query. With Spring Boot, `@RestController` turns a plain Java method into a routed HTTP endpoint, and Spring Data JPA generates an entire repository implementation — save, find, delete, everything — from a bare interface that declares no method bodies at all.

By the end of this tutorial you will have a small but complete REST API: a `Task` entity mapped to a database table, a `TaskRepository` interface that Spring Data JPA implements for you at startup, and a `TaskController` exposing `GET`, `POST`, `PUT`, and `DELETE` endpoints under `/api/tasks`. A `TaskRequest` DTO with Bean Validation annotations sits between the raw JSON body and the entity, so an incoming request with a blank title is rejected with a `400 Bad Request` before a single line of your own code runs.

What You'll Build
  • A `Task` `@Entity` with `id`, `title`, and `completed` fields, mapped to a table by Spring Data JPA.
  • A `TaskRepository extends JpaRepository<Task, Long>` — no boilerplate DAO code, just an interface.
  • A `TaskRequest` DTO using `@NotBlank` so `@Valid` rejects an invalid body automatically.
  • A `TaskController` with `GET /api/tasks` and `GET /api/tasks/{id}` endpoints.
  • A `POST /api/tasks` endpoint returning `201 Created` for a valid task.
  • `PUT /api/tasks/{id}` and `DELETE /api/tasks/{id}` endpoints completing full CRUD.

Prerequisites

  • Java fundamentals — classes, interfaces, and constructors.
  • HTTP basics — the meaning of `GET`, `POST`, `PUT`, `DELETE`, and status codes like `200`, `201`, `204`, `400`, and `404`.
  • Spring Boot basics — `@SpringBootApplication`, `@RestController`, and how Spring's dependency injection wires a constructor parameter automatically.
  • JSON basics — how a Java object maps to a JSON request/response body via Jackson, which Spring Boot configures for you by default.
  • A Spring Boot 3.x project with the Spring Web, Spring Data JPA, Validation, and H2 Database starters (all selectable from Spring Initializr).

Project Structure

Generating a project from Spring Initializr with `com.programinds.taskmanager` as the group/package produces this layout: `src/main/java/com/programinds/taskmanager/TaskManagerApplication.java` holds `main()`; a `model/` package holds `Task`; a `repository/` package holds `TaskRepository`; a `dto/` package holds `TaskRequest`; a `controller/` package holds `TaskController`; and `src/main/resources/application.properties` holds configuration such as the datasource URL. This is the same package-per-layer shape you will see used again in the remaining three projects in this course.

This project uses the H2 in-memory database — nothing needs to be installed beyond the JDK, and the schema is created automatically from the `Task` entity every time the app starts. A real deployment would swap the datasource properties in `application.properties` for a PostgreSQL or MySQL connection string; every line of `Task`, `TaskRepository`, and `TaskController` below would stay exactly the same, because Spring Data JPA's generated queries are database-independent.

Step 1: Create the Task Entity

`@Entity` is what tells Spring Data JPA that this plain Java class maps to a database table — by convention, a table named `task`, one column per field. `@Id` marks the primary key, and `@GeneratedValue(strategy = GenerationType.IDENTITY)` delegates id generation to the database's own auto-increment column, so `TaskController` never has to invent an id itself. JPA also requires a no-argument constructor on every entity, since it builds objects via reflection when reading rows back from the database.

model/Task.java
package com.programinds.taskmanager.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
// @Entity tells Spring Data JPA to map this class to a table (named "task" by
// default). No hand-written SQL or mapping XML is needed for a class this simple.
@Entity
public class Task {
@Id // Marks this field as the table's primary key
@GeneratedValue(strategy = GenerationType.IDENTITY) // Let the database auto-increment the id column
private Long id;
private String title; // What the task is; validated as non-blank by TaskRequest in Step 3
private boolean completed; // Defaults to false, matching boolean's own default value
// JPA requires a no-arg constructor so it can instantiate Task via reflection
// when mapping a database row back into an object.
public Task() {
}
// Convenience constructor used by TaskController when creating a new task
// from a validated TaskRequest — id is left null since IDENTITY assigns it.
public Task(String title, boolean completed) {
this.title = title;
this.completed = completed;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public boolean isCompleted() {
return completed;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}

Step 2: Create the TaskRepository

This is the step that shows why Spring Data JPA exists. `TaskRepository` declares no method bodies at all — it just extends `JpaRepository<Task, Long>`, naming the entity type and its id type. At application startup, Spring Data JPA generates a full implementation behind the scenes, giving you `save()`, `findAll()`, `findById()`, `deleteById()`, `existsById()`, and dozens more, without a single line of hand-written DAO code.

repository/TaskRepository.java
package com.programinds.taskmanager.repository;
import com.programinds.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
// Extending JpaRepository<Task, Long> is the entire implementation. Spring Data
// JPA generates save(), findAll(), findById(), deleteById(), existsById(), and
// more at startup — this is the "no boilerplate DAO code" Spring Data promises.
public interface TaskRepository extends JpaRepository<Task, Long> {
// Empty on purpose. A derived query method could be added later, e.g.:
// List<Task> findByCompleted(boolean completed);
// and Spring Data JPA would generate its SQL from the method name alone.
}

Step 3: Add a Validated Request DTO

The controller in the next three steps never accepts a raw `Task` entity as a request body — it accepts a `TaskRequest` DTO instead. Separating the two matters because `Task` has an `id` field that the client should never be allowed to set directly, and because Bean Validation annotations like `@NotBlank` belong on the boundary of the API, not baked permanently into the persistence entity. `@Valid` on the controller parameter (Step 5) is what actually triggers this validation.

dto/TaskRequest.java
package com.programinds.taskmanager.dto;
import jakarta.validation.constraints.NotBlank;
// A request DTO, deliberately separate from the Task entity. It has no id field
// (the client never chooses one) and carries validation annotations that only
// make sense at the API boundary, not on the persisted entity itself.
public class TaskRequest {
@NotBlank(message = "Title must not be blank") // Rejects null, empty, and whitespace-only titles
private String title;
private boolean completed; // Optional on create; defaults to false via boolean's default value
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public boolean isCompleted() {
return completed;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}

Step 4: Build the GET Endpoints

`@RestController` combines `@Controller` and `@ResponseBody`, meaning every method's return value is serialized straight to JSON instead of resolved as a view name. `@RequestMapping("/api/tasks")` on the class sets a shared prefix for every method below it. The constructor taking a `TaskRepository` is Spring's dependency injection at work: Spring creates one `TaskController` and hands it the same `TaskRepository` bean it built for the whole application, with no `new TaskRepositoryImpl()` anywhere in this file.

controller/TaskController.java
package com.programinds.taskmanager.controller;
import com.programinds.taskmanager.model.Task;
import com.programinds.taskmanager.repository.TaskRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController // Combines @Controller + @ResponseBody: every method's return value becomes JSON
@RequestMapping("/api/tasks") // Shared URL prefix for every endpoint in this controller
public class TaskController {
private final TaskRepository taskRepository; // final: assigned once, in the constructor, never reassigned
// Constructor injection: Spring passes in the single TaskRepository bean it
// already built at startup. No field is ever null, and the class is trivially
// testable by passing in a mock TaskRepository instead.
public TaskController(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@GetMapping // GET /api/tasks
public List<Task> getAll() {
return taskRepository.findAll(); // Generated by Spring Data JPA in Step 2; no SQL written by hand
}
@GetMapping("/{id}") // GET /api/tasks/5
public ResponseEntity<Task> getById(@PathVariable Long id) {
return taskRepository.findById(id) // Returns an Optional<Task>
.map(ResponseEntity::ok) // Present: wrap it in 200 OK
.orElse(ResponseEntity.notFound().build()); // Absent: 404, with no body
}
}

Step 5: Build the POST Endpoint

`@Valid` is the annotation that actually triggers the `@NotBlank` check written on `TaskRequest` back in Step 3 — without it, Spring would bind the JSON body into a `TaskRequest` object but never run its validation constraints, and a blank title would sail straight through. When validation fails, Spring throws a `MethodArgumentNotValidException` before `create()` ever runs, and Spring Boot's default handler turns that into a `400 Bad Request` automatically, with no `try`/`catch` needed in this method at all.

@PostMapping // POST /api/tasks
public ResponseEntity<Task> create(@Valid @RequestBody TaskRequest request) {
// @Valid triggers TaskRequest's @NotBlank check; an invalid body never
// reaches this line — Spring responds with 400 Bad Request automatically.
Task task = new Task(request.getTitle(), request.isCompleted());
Task saved = taskRepository.save(task); // INSERT; saved.getId() is now populated by the database
return ResponseEntity.status(HttpStatus.CREATED).body(saved); // 201 Created, echoing the persisted task back
}

Step 6: Build the PUT and DELETE Endpoints

`update()` loads the existing entity with `findById()` and mutates its fields directly rather than constructing a brand-new `Task` — this matters because Spring Data JPA's `save()` on an entity that already has an id issues an `UPDATE`, not an `INSERT`. `delete()` checks `existsById()` first so a `DELETE` on a nonexistent id returns `404` instead of silently succeeding, which is the more honest response for a client that got the id wrong.

@PutMapping("/{id}") // PUT /api/tasks/5
public ResponseEntity<Task> update(@PathVariable Long id, @Valid @RequestBody TaskRequest request) {
return taskRepository.findById(id)
.map(task -> {
task.setTitle(request.getTitle()); // Mutate the loaded entity's fields directly
task.setCompleted(request.isCompleted());
return ResponseEntity.ok(taskRepository.save(task)); // save() on an entity with an id issues an UPDATE
})
.orElse(ResponseEntity.notFound().build()); // No task with that id: 404
}
@DeleteMapping("/{id}") // DELETE /api/tasks/5
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!taskRepository.existsById(id)) { // Check first so a bad id gets 404, not a silent no-op
return ResponseEntity.notFound().build();
}
taskRepository.deleteById(id);
return ResponseEntity.noContent().build(); // 204: the delete succeeded, nothing meaningful to return
}

Complete Code

Here is the full project across its files, plus the application entry point and `application.properties`. Run it with `./mvnw spring-boot:run` (or your IDE's run button) — the H2 in-memory database and schema are created automatically on startup.

TaskManagerApplication.java
package com.programinds.taskmanager;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication // Enables auto-configuration, component scanning, and the embedded Tomcat server
public class TaskManagerApplication {
public static void main(String[] args) {
SpringApplication.run(TaskManagerApplication.class, args);
}
}
application.properties
spring.application.name=task-manager
# H2 in-memory database: no install required, resets every restart. A real
# deployment would replace these three lines with a PostgreSQL/MySQL connection.
spring.datasource.url=jdbc:h2:mem:taskdb
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true
model/Task.java
package com.programinds.taskmanager.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private boolean completed;
public Task() {
}
public Task(String title, boolean completed) {
this.title = title;
this.completed = completed;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public boolean isCompleted() {
return completed;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}
repository/TaskRepository.java
package com.programinds.taskmanager.repository;
import com.programinds.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
}
dto/TaskRequest.java
package com.programinds.taskmanager.dto;
import jakarta.validation.constraints.NotBlank;
public class TaskRequest {
@NotBlank(message = "Title must not be blank")
private String title;
private boolean completed;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public boolean isCompleted() {
return completed;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}
controller/TaskController.java
package com.programinds.taskmanager.controller;
import com.programinds.taskmanager.dto.TaskRequest;
import com.programinds.taskmanager.model.Task;
import com.programinds.taskmanager.repository.TaskRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskRepository taskRepository;
public TaskController(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@GetMapping
public List<Task> getAll() {
return taskRepository.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<Task> getById(@PathVariable Long id) {
return taskRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Task> create(@Valid @RequestBody TaskRequest request) {
Task task = new Task(request.getTitle(), request.isCompleted());
Task saved = taskRepository.save(task);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@PutMapping("/{id}")
public ResponseEntity<Task> update(@PathVariable Long id, @Valid @RequestBody TaskRequest request) {
return taskRepository.findById(id)
.map(task -> {
task.setTitle(request.getTitle());
task.setCompleted(request.isCompleted());
return ResponseEntity.ok(taskRepository.save(task));
})
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!taskRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
taskRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a derived query method, `List<Task> findByCompleted(boolean completed)`, to `TaskRepository` and a `GET /api/tasks?completed=true` filter that calls it.
  • Add a `@ControllerAdvice`-annotated `GlobalExceptionHandler` that turns a failed `@Valid` check into a structured JSON error body instead of Spring Boot's default error page.
  • Add a `dueDate` field to `Task` with `@Future` validation, and a `GET /api/tasks/overdue` endpoint.
  • Swap the H2 in-memory datasource for PostgreSQL by changing only the three properties in `application.properties`.
  • Add pagination with `Pageable` and `taskRepository.findAll(Pageable)` so `GET /api/tasks` supports `?page=0&size=10`.

Summary

You built a working REST API where `TaskController` routes HTTP requests to strongly typed methods, `TaskRepository` generates its entire implementation from a bare interface, and `@Valid` rejects a malformed request before it ever reaches your own code. The `getAll`/`getById`/`create`/`update`/`delete` shape you wrote here — a thin controller delegating straight to a Spring Data JPA repository — is the same shape almost every simple CRUD API in Spring Boot follows.