Building RESTful APIs with Spring Boot: A Step-by-Step Guide

Master the art of creating robust and scalable REST APIs using Spring Boot framework

Introduction to RESTful APIs and Spring Boot

REST (Representational State Transfer) APIs have become the backbone of modern web applications, enabling seamless communication between different systems. Spring Boot, with its convention-over-configuration approach, makes building these APIs remarkably straightforward and efficient.

In this comprehensive guide, we’ll walk through the entire process of creating a production-ready RESTful API using Spring Boot, covering everything from initial setup to advanced features and best practices.

Prerequisites

Before diving into the implementation, ensure you have the following tools installed:

  • Java 17 or higher – The foundation of our Spring Boot application
  • Maven or Gradle – For dependency management and build automation
  • IDE – IntelliJ IDEA, Eclipse, or Visual Studio Code
  • Postman or cURL – For testing our API endpoints

Step 1: Setting Up Your Spring Boot Project

Using Spring Initializr

The fastest way to bootstrap a Spring Boot project is through Spring Initializr:

  1. Navigate to https://start.spring.io/
  2. Select Maven Project with Java
  3. Choose Spring Boot version 3.2.x (latest stable)
  4. Enter your project metadata:
    • Group: com.example
    • Artifact: bookstore-api
    • Name: Bookstore API
    • Package name: com.example.bookstore
  5. Add the following dependencies:
    • Spring Web – For building REST APIs
    • Spring Data JPA – For database operations
    • H2 Database – In-memory database for development
    • Spring Boot DevTools – For hot reloading
    • Validation – For input validation

Maven Dependencies

Your pom.xml should include these key dependencies:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

Step 2: Creating the Data Model

Let’s create a simple Book entity for our bookstore API:

package com.example.bookstore.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "books")
public class Book {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NotBlank(message = "Title is required")
    @Size(max = 200, message = "Title must not exceed 200 characters")
    @Column(nullable = false)
    private String title;
    
    @NotBlank(message = "Author is required")
    @Size(max = 100, message = "Author name must not exceed 100 characters")
    @Column(nullable = false)
    private String author;
    
    @NotBlank(message = "ISBN is required")
    @Pattern(regexp = "^\\d{10}(\\d{3})?$", message = "Invalid ISBN format")
    @Column(unique = true, nullable = false)
    private String isbn;
    
    @DecimalMin(value = "0.0", inclusive = false, message = "Price must be greater than 0")
    @Column(nullable = false)
    private Double price;
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
    
    // Constructors
    public Book() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }
    
    public Book(String title, String author, String isbn, Double price) {
        this();
        this.title = title;
        this.author = author;
        this.isbn = isbn;
        this.price = price;
    }
    
    // Getters and Setters
    // ... (include all getters and setters)
    
    @PreUpdate
    public void preUpdate() {
        this.updatedAt = LocalDateTime.now();
    }
}

Step 3: Creating the Repository Layer

Spring Data JPA makes database operations incredibly simple with repository interfaces:

package com.example.bookstore.repository;

import com.example.bookstore.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
    
    // Find book by ISBN
    Optional<Book> findByIsbn(String isbn);
    
    // Find books by author (case-insensitive)
    List<Book> findByAuthorContainingIgnoreCase(String author);
    
    // Find books by title (case-insensitive)
    List<Book> findByTitleContainingIgnoreCase(String title);
    
    // Custom query to find books within price range
    @Query("SELECT b FROM Book b WHERE b.price BETWEEN :minPrice AND :maxPrice")
    List<Book> findBooksByPriceRange(@Param("minPrice") Double minPrice, 
                                     @Param("maxPrice") Double maxPrice);
}

Step 4: Implementing the Service Layer

The service layer contains our business logic and acts as an intermediary between controllers and repositories:

package com.example.bookstore.service;

import com.example.bookstore.model.Book;
import com.example.bookstore.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;

@Service
public class BookService {
    
    @Autowired
    private BookRepository bookRepository;
    
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }
    
    public Optional<Book> getBookById(Long id) {
        return bookRepository.findById(id);
    }
    
    public Optional<Book> getBookByIsbn(String isbn) {
        return bookRepository.findByIsbn(isbn);
    }
    
    public Book saveBook(Book book) {
        // Check if ISBN already exists
        if (bookRepository.findByIsbn(book.getIsbn()).isPresent()) {
            throw new RuntimeException("Book with ISBN " + book.getIsbn() + " already exists");
        }
        return bookRepository.save(book);
    }
    
    public Book updateBook(Long id, Book bookDetails) {
        return bookRepository.findById(id)
                .map(book -> {
                    book.setTitle(bookDetails.getTitle());
                    book.setAuthor(bookDetails.getAuthor());
                    book.setPrice(bookDetails.getPrice());
                    // Don't update ISBN to maintain uniqueness
                    return bookRepository.save(book);
                })
                .orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
    }
    
    public void deleteBook(Long id) {
        if (!bookRepository.existsById(id)) {
            throw new RuntimeException("Book not found with id: " + id);
        }
        bookRepository.deleteById(id);
    }
    
    public List<Book> searchBooksByAuthor(String author) {
        return bookRepository.findByAuthorContainingIgnoreCase(author);
    }
    
    public List<Book> searchBooksByTitle(String title) {
        return bookRepository.findByTitleContainingIgnoreCase(title);
    }
}

Step 5: Building the REST Controller

Now let’s create our REST controller that exposes the API endpoints:

package com.example.bookstore.controller;

import com.example.bookstore.model.Book;
import com.example.bookstore.service.BookService;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/books")
@CrossOrigin(origins = "*")
public class BookController {
    
    @Autowired
    private BookService bookService;
    
    // GET /api/books - Retrieve all books
    @GetMapping
    public ResponseEntity<List<Book>> getAllBooks() {
        List<Book> books = bookService.getAllBooks();
        return ResponseEntity.ok(books);
    }
    
    // GET /api/books/{id} - Retrieve a specific book
    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return bookService.getBookById(id)
                .map(book -> ResponseEntity.ok(book))
                .orElse(ResponseEntity.notFound().build());
    }
    
    // POST /api/books - Create a new book
    @PostMapping
    public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
        try {
            Book savedBook = bookService.saveBook(book);
            return ResponseEntity.status(HttpStatus.CREATED).body(savedBook);
        } catch (RuntimeException e) {
            return ResponseEntity.badRequest().build();
        }
    }
    
    // PUT /api/books/{id} - Update an existing book
    @PutMapping("/{id}")
    public ResponseEntity<Book> updateBook(@PathVariable Long id, 
                                          @Valid @RequestBody Book bookDetails) {
        try {
            Book updatedBook = bookService.updateBook(id, bookDetails);
            return ResponseEntity.ok(updatedBook);
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }
    
    // DELETE /api/books/{id} - Delete a book
    @DeleteMapping("/{id}")
    public ResponseEntity<?> deleteBook(@PathVariable Long id) {
        try {
            bookService.deleteBook(id);
            return ResponseEntity.ok().build();
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }
    
    // GET /api/books/search/author?name={author} - Search by author
    @GetMapping("/search/author")
    public ResponseEntity<List<Book>> searchByAuthor(@RequestParam String name) {
        List<Book> books = bookService.searchBooksByAuthor(name);
        return ResponseEntity.ok(books);
    }
    
    // GET /api/books/search/title?name={title} - Search by title
    @GetMapping("/search/title")
    public ResponseEntity<List<Book>> searchByTitle(@RequestParam String name) {
        List<Book> books = bookService.searchBooksByTitle(name);
        return ResponseEntity.ok(books);
    }
}

Step 6: Exception Handling

Implement global exception handling for better error responses:

package com.example.bookstore.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationExceptions(
            MethodArgumentNotValidException ex) {
        Map<String, Object> response = new HashMap<>();
        Map<String, String> errors = new HashMap<>();
        
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        
        response.put("timestamp", LocalDateTime.now());
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("error", "Validation Failed");
        response.put("message", errors);
        
        return ResponseEntity.badRequest().body(response);
    }
    
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException ex) {
        Map<String, Object> response = new HashMap<>();
        response.put("timestamp", LocalDateTime.now());
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("error", "Bad Request");
        response.put("message", ex.getMessage());
        
        return ResponseEntity.badRequest().body(response);
    }
}

Step 7: Configuration

Configure your application.properties file:

# Server Configuration
server.port=8080

# Database Configuration (H2 for development)
spring.datasource.url=jdbc:h2:mem:bookstore
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA Configuration
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.format-sql=true

# H2 Console (for development only)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Logging
logging.level.com.example.bookstore=DEBUG
logging.level.org.springframework.web=DEBUG

Step 8: Testing Your API

Manual Testing with Postman

Here are the key endpoints to test:

  1. GET http://localhost:8080/api/books – Retrieve all books
  2. POST http://localhost:8080/api/books – Create a new book
    {
      "title": "Spring Boot in Action",
      "author": "Craig Walls",
      "isbn": "9781617292545",
      "price": 39.99
    }
  3. GET http://localhost:8080/api/books/1 – Get book by ID
  4. PUT http://localhost:8080/api/books/1 – Update a book
  5. DELETE http://localhost:8080/api/books/1 – Delete a book
Unit Testing

Create unit tests for your service layer:

@SpringBootTest
class BookServiceTest {
    
    @Autowired
    private BookService bookService;
    
    @Test
    void shouldCreateBook() {
        Book book = new Book("Test Book", "Test Author", "1234567890", 25.99);
        Book savedBook = bookService.saveBook(book);
        
        assertThat(savedBook.getId()).isNotNull();
        assertThat(savedBook.getTitle()).isEqualTo("Test Book");
    }
    
    @Test
    void shouldThrowExceptionForDuplicateIsbn() {
        Book book1 = new Book("Book 1", "Author 1", "1234567890", 25.99);
        Book book2 = new Book("Book 2", "Author 2", "1234567890", 35.99);
        
        bookService.saveBook(book1);
        
        assertThrows(RuntimeException.class, () -> bookService.saveBook(book2));
    }
}

Best Practices and Advanced Features

1. API Versioning

Always version your APIs for backward compatibility:

@RestController
@RequestMapping("/api/v1/books")
public class BookController {
    // Implementation
}

2. Pagination and Sorting

Implement pagination for large datasets:

@GetMapping
public ResponseEntity<Page<Book>> getAllBooks(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size,
        @RequestParam(defaultValue = "id") String sortBy) {
    
    Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy));
    Page<Book> books = bookService.getAllBooks(pageable);
    return ResponseEntity.ok(books);
}
3. API Documentation with OpenAPI

Add Swagger for automatic API documentation:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.2.0</version>
</dependency>
4. Security

Implement authentication and authorization:

  • Add Spring Security dependency
  • Configure JWT token-based authentication
  • Implement role-based access control
  • Use HTTPS in production
5. Caching

Improve performance with caching:

@Service
public class BookService {
    
    @Cacheable("books")
    public Optional<Book> getBookById(Long id) {
        return bookRepository.findById(id);
    }
    
    @CacheEvict(value = "books", key = "#id")
    public void deleteBook(Long id) {
        bookRepository.deleteById(id);
    }
}

Production Deployment Considerations

Database Configuration

Replace H2 with a production database like PostgreSQL:

# PostgreSQL Configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/bookstore
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=validate
Environment-Specific Profiles

Use Spring profiles for different environments:

  • application-dev.properties – Development settings
  • application-prod.properties – Production settings
  • application-test.properties – Testing settings
Health Checks and Monitoring

Add Spring Boot Actuator for monitoring:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Conclusion

Building RESTful APIs with Spring Boot is both powerful and straightforward. We’ve covered the essential components including data modeling, repository patterns, service layers, REST controllers, exception handling, and testing strategies.

The key takeaways from this guide include:

  • Convention over Configuration – Spring Boot’s opinionated defaults reduce boilerplate code
  • Layered Architecture – Separating concerns improves maintainability and testability
  • Validation and Error Handling – Proper validation ensures data integrity and better user experience
  • RESTful Design – Following REST principles creates intuitive and consistent APIs
  • Testing Strategy – Comprehensive testing ensures reliability and facilitates refactoring

As you advance, consider implementing additional features like API rate limiting, comprehensive logging, database migrations with Flyway, and containerization with Docker. Remember that building great APIs is an iterative process that improves with experience and user feedback.

Start building your next API project with Spring Boot, and leverage its extensive ecosystem to create robust, scalable, and maintainable applications that serve your users effectively.

Leave a comment