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.
- A
Task@Entitywithid,title, andcompletedfields, mapped to a table by Spring Data JPA. - A
TaskRepository extends JpaRepository<Task, Long>— no boilerplate DAO code, just an interface. - A
TaskRequestDTO using@NotBlankso@Validrejects an invalid body automatically. - A
TaskControllerwithGET /api/tasksandGET /api/tasks/{id}endpoints. - A
POST /api/tasksendpoint returning201 Createdfor a valid task. PUT /api/tasks/{id}andDELETE /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 like200,201,204,400, and404. - 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.
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.@Entitypublic 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.
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.
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.
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 controllerpublic 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/taskspublic 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/5public 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/5public 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.
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 serverpublic class TaskManagerApplication { public static void main(String[] args) { SpringApplication.run(TaskManagerApplication.class, args); }}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:taskdbspring.datasource.driver-class-name=org.h2.Driverspring.jpa.hibernate.ddl-auto=updatespring.h2.console.enabled=truepackage com.programinds.taskmanager.model;
import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;
@Entitypublic 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; }}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> {}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; }}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
Click Run to see what this code prints.
Extend This Project
- Add a derived query method,
List<Task> findByCompleted(boolean completed), toTaskRepositoryand aGET /api/tasks?completed=truefilter that calls it. - Add a
@ControllerAdvice-annotatedGlobalExceptionHandlerthat turns a failed@Validcheck into a structured JSON error body instead of Spring Boot's default error page. - Add a
dueDatefield toTaskwith@Futurevalidation, and aGET /api/tasks/overdueendpoint. - Swap the H2 in-memory datasource for PostgreSQL by changing only the three properties in
application.properties. - Add pagination with
PageableandtaskRepository.findAll(Pageable)soGET /api/taskssupports?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.