LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.