DEV Community

Arshad M Patel
Arshad M Patel

Posted on

Implementing a RESTful Web API with Spring Boot

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)

  1. Create a Task POST /api/tasks
    Use case: Add a new task with title, description, category, and default status as PENDING.

  2. Get All Tasks GET /api/tasks
    Use case: Retrieve all tasks available in the system.

  3. Get Task by ID GET /api/tasks/{id}
    Use case: Fetch details of a single task using its ID.

  4. Update a Task PUT /api/tasks/{id}
    Use case: Modify a task’s details such as title, description, category, or status.

  5. Delete a Task DELETE /api/tasks/{id}
    Use case: Remove a task from the system.

  6. 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.

Structure of our project

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.). Spring initializer for reference

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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> {

}
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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";
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Test it using Postman or any API client by sending a POST request with a JSON body like:

{
    "title" : "Rest API",
    "status": "Pending"
}
Enter fullscreen mode Exit fullscreen mode

Postman reference

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Postman Reference

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }

}
Enter fullscreen mode Exit fullscreen mode

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)