Building a REST API is one of the most essential skills for any backend developer. Spring Boot makes this incredibly simple by providing a production-ready environment with minimal configuration. In this article, we’ll walk through the core concepts of REST and how to implement a clean, scalable REST API using Spring Boot.
RESTful API?
REST (Representational State Transfer) is an architectural style that defines a set of rules for creating scalable web services. REST APIs use HTTP methods like:
- GET – Retrieve data
- POST – Create data
- PUT – Update data
- DELETE – Remove data
REST APIs are stateless, meaning each request is independent and contains all required information.
What We Are Going to Build?
In this project, we will build a Task Tracker REST API using Spring Boot. The goal is to design a clean, meaningful, and production-ready API that allows users to manage their daily tasks efficiently.
Endpoints We Will Build (with Use Cases)
Create a Task
POST /api/tasks
Use case: Add a new task with title, description, category, and default status as PENDING.Get All Tasks
GET /api/tasks
Use case: Retrieve all tasks available in the system.Get Task by ID
GET /api/tasks/{id}
Use case: Fetch details of a single task using its ID.Update a Task
PUT /api/tasks/{id}
Use case: Modify a task’s details such as title, description, category, or status.Delete a Task
DELETE /api/tasks/{id}
Use case: Remove a task from the system.Get Tasks by Status
GET /api/tasks/status/{status}
Use case: Filter tasks based on their status — PENDING, COMPLETED.
Database Used : H2 In-Memory Database
For simplicity and ease of demonstration, we will use H2 in-memory database, which allows:
- Zero installation
- Automatic console UI
- Fast development
- Easy testing
Note : It resets on application restart, which is perfect for tutorial purposes.
To build a clean and maintainable REST API, we follow a layered architecture consisting of three main layers: Controller (handles incoming API requests), Service (contains business logic), and Repository (manages database interactions). Now that we understand what we’re building, let’s begin with the Controller layer and implement all our endpoints one by one.
Setting Up the Project
Now that we understand our architecture and API structure, let’s start building the application. The first step is to create a Spring Boot project with the necessary dependencies.
1. Create a New Spring Boot Project
Head over to Spring Initializr at:
Here, we will generate a fresh Spring Boot project with all the required configurations.
Fill in the following project details:
- Project: Maven
- Language: Java
- Spring Boot Version: Latest stable version
- Group: com.example (or your preferred package)
- Artifact: task-tracker-api
- Name: Task Tracker API
- Packaging: Jar
- Java Version: 17 or above
2. Add the Required Dependencies
To build a functional REST API, we need to add the following dependencies:
- Spring Web - Allows us to create REST controllers and expose HTTP endpoints.
- Spring Data JPA - Helps us interact with the database in an easy, ORM-based way.
- H2 Database - A lightweight in-memory database ideal for development and testing.
- Lombok - Reduces boilerplate code (getters, setters, constructors, etc.).
Once you have added these dependencies, click Generate.
This will download a ZIP project which you can extract and open in your IDE (IntelliJ IDEA / Eclipse / VS Code).
3. Run the Application
Navigate to the main class: TaskTrackerApiApplication.java and run the application. If everything is configured correctly, you’ll see: Started TaskTrackerApiApplication in X seconds
Congratulations!!!
Your Spring Boot application is now running...
4. Controller Layer
Create a class named TaskController and place it in the controller package. This layer handles incoming HTTP requests and sends responses back to the client.
package com.arshadpatel.task_tracker_api.controller;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
// 1. Create a Task
// 2. Get All Tasks
// 3. Get Task by ID
// 4. Update a Task
// 5. Delete a Task
// 6. Get Tasks by Status
}
5. Model
Next, create Task.java a model class in the model package:
package com.arshadpatel.task_tracker_api.model;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String status;
}
Meaning of annotations in simple terms:
- @ Data → Automatically generates getters, setters, toString(), equals(), and hashCode().
- @NoArgsConstructor → Creates a constructor with no arguments.
- @AllArgsConstructor → Creates a constructor with all fields.
- @Entity → Marks this class as a database table.
- @ Id → Marks the primary key of the table.
- @GeneratedValue → Automatically generates a value for the primary key.
6. Repository and Service Layers
TaskRepository.java : This layer interacts directly with the database. All database queries are handled here. In our case, the database is H2.
package com.arshadpatel.task_tracker_api.repository;
import com.arshadpatel.task_tracker_api.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
}
TaskService.java : This layer contains the core logic and decides how responses are sent.
package com.arshadpatel.task_tracker_api.service;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.repository.TaskRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
@Autowired
private TaskRepository taskRepository;
// 1. Create a Task
// 2. Get All Tasks
// 3. Get Task by ID
// 4. Update Task
// 5. Delete Task
// 6. Get Tasks by Status
}
7. Connecting Controller and Service
Now we will implement the first API method in the controller to create a task. After that, we can test it using Postman or any API client by sending a POST request with a JSON body.
TaskController.java
package com.arshadpatel.task_tracker_api.controller;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.service.TaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class TaskController {
@Autowired
private TaskService taskService;
// 1. Create a Task
@PostMapping("/task")
public String createTask(@RequestBody Task task) {
taskService.createTask(task);
return "Task created successfully";
}
}
TaskService.java
package com.arshadpatel.task_tracker_api.service;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.repository.TaskRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
@Autowired
private TaskRepository taskRepository;
// 1. Create a Task
public void createTask(Task task) {
taskRepository.save(task);
}
}
Test it using Postman or any API client by sending a POST request with a JSON body like:
{
"title" : "Rest API",
"status": "Pending"
}
Hit it at http://localhost:8080/task, and you should get the response: Task created successfully To verify that the task is really saved in the database, we will implement the next method to fetch all tasks. After adding the method, you can create a GET request to see the saved tasks.
TaskController.java
package com.arshadpatel.task_tracker_api.controller;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.service.TaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class TaskController {
@Autowired
private TaskService taskService;
// 1. Create a Task
@PostMapping("/task")
public String createTask(@RequestBody Task task) {
taskService.createTask(task);
return "Task created successfully";
}
// 2. Get All Tasks
@GetMapping("/tasks")
public List<Task> getAllTasks() {
return taskService.getAllTasks();
}
}
TaskService.java
package com.arshadpatel.task_tracker_api.service;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.repository.TaskRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
@Autowired
private TaskRepository taskRepository;
// 1. Create a Task
public void createTask(Task task) {
taskRepository.save(task);
}
// 2. Get All Tasks
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
}
After running the application, first send the POST request you created recently to add a task. Once that is done, create another request with the HTTP method GET and the URL: http://localhost:8080/tasks. This will fetch all the tasks stored in the database and allow you to verify that your POST request successfully added a new task.
8. Completing the Remaining API Methods
Now that we have implemented the “Create” and “Get All” methods, it's time to complete the remaining CRUD operations. These include:
- Get a Task by ID
- Update a Task
- Delete a Task
- Get Tasks by Status
TaskController.java
package com.arshadpatel.task_tracker_api.controller;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.service.TaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class TaskController {
@Autowired
private TaskService taskService;
// 1. Create a Task
@PostMapping("/task")
public String createTask(@RequestBody Task task) {
taskService.createTask(task);
return "Task created successfully";
}
// 2. Get All Tasks
@GetMapping("/tasks")
public List<Task> getAllTasks() {
return taskService.getAllTasks();
}
// 3. Get Task by ID
@GetMapping("task/{id}")
public Task getTaskById(@PathVariable Long id) {
return taskService.getTaskById(id);
}
// 4. Update a Task
@PutMapping("task/{id}")
public Task updateTask(@PathVariable Long id, @RequestBody Task task) {
taskService.updateTask(id, task);
return taskService.getTaskById(id);
}
// 5. Delete a Task
@DeleteMapping("task/{id}")
public String deleteTask(@PathVariable Long id) {
taskService.deleteTask(id);
return "Task deleted successfully";
}
// 6. Get Tasks by Status
@GetMapping("/status/{status}")
public String getTasksByStatus(@PathVariable String status) {
taskService.getTasksByStatus(status);
return "Fetching tasks with status: " + status;
}
}
TaskService.java
package com.arshadpatel.task_tracker_api.service;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.repository.TaskRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
@Autowired
private TaskRepository taskRepository;
// 1. Create a Task
public void createTask(Task task) {
taskRepository.save(task);
}
// 2. Get All Tasks
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
// 3. Get Task by ID
public Task getTaskById(Long id) {
return taskRepository.findById(id)
.orElse(null);
}
// 4. Update Task
public Task updateTask(Long id, Task updatedTask) {
return taskRepository.findById(id).map(task -> {
task.setTitle(updatedTask.getTitle());
task.setStatus(updatedTask.getStatus());
return taskRepository.save(task);
}).orElse(null);
}
// 5. Delete Task
public String deleteTask(Long id) {
taskRepository.deleteById(id);
return "successfully deleted";
}
// 6. Get Tasks by Status
public List<Task> getTasksByStatus(String status) {
return taskRepository.findByStatus(status);
}
}
TaskRepository.java
package com.arshadpatel.task_tracker_api.repository;
import com.arshadpatel.task_tracker_api.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByStatus(String status);
}
You have now implemented all major features of a REST API using Spring Boot!
Congratulations! You Have Successfully Completed All CRUD Operations!
This is a huge milestone in backend development.
At this point, you have built a fully functional REST API that can:
- Create data
- Retrieve data
- Update data
- Delete data
- Filter data
This is exactly what real-world APIs do.
You’ve officially reached the stage where you're not just "learning Spring Boot" you are actually building meaningful backend applications.
Be proud of this progress — you have earned it!
Scenarios Where This Application Will Break
Right now, your API works — but it’s not safe.
There are several scenarios where the backend will crash, or return wrong responses, or behave unpredictably.
Below are some real examples based on your current code:
Deleting a Task that Does NOT Exist
DELETE /task/500
If task with ID 500 doesn’t exist:
- deleteById() will throw an exception (EmptyResultDataAccessException)
- The server will crash internally
- The user sees a 500 Internal Server Error
Updating a Task that Does NOT Exist
PUT /task/999
{
"title": "New Title",
"status": "Pending"
}
So what happens?
- Backend returns null
- Controller still calls taskService.getTaskById(id)
- That returns null again
- User sees an empty response (not meaningful)
- No clear indication that the task was not found This is confusing for the client.
No HTTP Status Codes Are Being Sent
Every response from your controller returns:
“Task created successfully”
but without proper status codes like 201, 404, 400, 500, etc.
Clients expect these:
200 - OK (Success)
201 - Created (Resource created successfully)
400 - Bad Request (Invalid input)
404 - Not Found (Resource missing)
500 - Internal Server Error (Something broke internally)
Your current API sends only plain text → not good for real consumers.
All these issues can be solved by introducing:
- Custom Exceptions
- ResponseEntity
- Proper HTTP Status Codes
This makes your API professional, predictable, and user-friendly.
9. Adding Proper HTTP Status Codes Using ResponseEntity
Until now, our API has been returning plain Strings as responses.
But RESTful APIs must return proper HTTP status codes to clearly communicate whether a request was successful or failed.
What is ResponseEntity?
- ResponseEntity is a Spring class that lets you send both the response body and the HTTP status code together.
- It gives you full control over what the client receives.
For example, instead of just returning "Task created successfully", we can return:
- 201 Created (when a new task is added)
- 200 OK (when data is fetched or updated)
- 404 Not Found (when a task does not exist)
Now let’s improve our API to use ResponseEntity.
10. Creating a Standard Error Response (JSON)
To keep our API responses clean and consistent, we will create a simple Response class that can handle both:
- success messages
- error messages
This ensures clients always receive a clear JSON object instead of plain text.
Response.java
package com.arshadpatel.task_tracker_api.response;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class Response {
private String message;
}
11. Updating Controller With ResponseEntity and Basic If-Else Handling
Now we modify our controller so all responses are RESTful and predictable.
TaskController.java
package com.arshadpatel.task_tracker_api.controller;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.response.Response;
import com.arshadpatel.task_tracker_api.service.TaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class TaskController {
@Autowired
private TaskService taskService;
// 1. Create a Task
@PostMapping("/task")
public ResponseEntity<?> createTask(@RequestBody Task task) {
Task savedTask = taskService.createTask(task);
return new ResponseEntity<>(savedTask, HttpStatus.CREATED);
}
// 2. Get All Tasks
@GetMapping("/tasks")
public ResponseEntity<?> getAllTasks() {
return ResponseEntity.ok(taskService.getAllTasks());
}
// 3. Get Task by ID
@GetMapping("task/{id}")
public ResponseEntity<?> getTaskById(@PathVariable Long id) {
Task task = taskService.getTaskById(id);
if (task == null) {
return new ResponseEntity<>(new Response("Task not found"), HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(task);
}
// 4. Update a Task
@PutMapping("task/{id}")
public ResponseEntity<?> updateTask(@PathVariable Long id, @RequestBody Task updatedTask) {
Task task = taskService.updateTask(id, updatedTask);
if (task == null) {
return new ResponseEntity<>(new Response("Task not found"), HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(task);
}
// 5. Delete a Task
@DeleteMapping("task/{id}")
public ResponseEntity<?> deleteTask(@PathVariable Long id) {
boolean deleted = taskService.deleteTask(id);
if (!deleted) {
return new ResponseEntity<>(new Response("Task not found"), HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(new Response("Task deleted successfully"));
}
// 6. Get Tasks by Status
@GetMapping("/status/{status}")
public ResponseEntity<?> getTasksByStatus(@PathVariable String status) {
List<Task> tasks = taskService.getTasksByStatus(status);
return ResponseEntity.ok(tasks);
}
}
12. Updating Service Layer for Safe Operations
We now update the service to support our if–else based checks.
TaskService.java
package com.arshadpatel.task_tracker_api.service;
import com.arshadpatel.task_tracker_api.model.Task;
import com.arshadpatel.task_tracker_api.repository.TaskRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
@Autowired
private TaskRepository taskRepository;
// 1. Create a Task
public Task createTask(Task task) {
return taskRepository.save(task);
}
// 2. Get All Tasks
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
// 3. Get Task by ID
public Task getTaskById(Long id) {
return taskRepository.findById(id)
.orElse(null);
}
// 4. Update Task
public Task updateTask(Long id, Task updatedTask) {
return taskRepository.findById(id).map(task -> {
task.setTitle(updatedTask.getTitle());
task.setStatus(updatedTask.getStatus());
return taskRepository.save(task);
}).orElse(null);
}
// 5. Delete Task
public boolean deleteTask(Long id) {
if (!taskRepository.existsById(id)) {
return false;
}
taskRepository.deleteById(id);
return true;
}
// 6. Get Tasks by Status
public List<Task> getTasksByStatus(String status) {
return taskRepository.findByStatus(status);
}
}
Congratulations — Your API Is Now Fully RESTful!
At this point, your backend is no longer a simple CRUD application.
It now supports:
- Clean REST URLs
- Proper HTTP status codes
- JSON error messages
- Safe handling of invalid IDs
- Professional ResponseEntity usage
- Fully functional CRUD + filtering
This is just the beginning. Stay tuned for more articles where we’ll explore advanced Spring Boot and real-world REST API concepts.



Top comments (0)