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` `@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.
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)`, 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.