How to Build REST APIs with Spring Boot (Step-by-Step Guide)
Introduction
When I first started with Spring Boot a decade ago, I was amazed at how quickly I could build powerful REST APIs compared to the old days of complex XML configurations. Now, as a senior developer who's built countless APIs for startups and enterprises alike, I can tell you that Spring Boot has only gotten better with time.
If you're new to Spring Boot or just looking to refine your API development skills, you're in the right place. In this guide, I'll walk you through building REST APIs from scratch using Spring Boot, giving you the exact steps I follow when starting a new project.
REST APIs are the backbone of modern applications, enabling everything from mobile apps to single-page web applications to interact with your backend systems. Spring Boot simplifies this process tremendously with its auto-configuration and convention-over-configuration approach.
What makes this guide different? Instead of abstract concepts, we'll build a practical API for a real-world scenario: a product inventory management system. By the end, you'll have a working API that you can extend for your own projects.
Why Build REST APIs with Spring Boot?
Benefits of Using Spring Boot for APIs
- Rapid Development: Spring Boot's starter dependencies and auto-configuration save days of setup time.
- Production-Ready: Built-in metrics, health checks, and externalized configuration make your APIs ready for the real world.
- Highly Scalable: Spring Boot applications can scale from simple microservices to enterprise-grade systems.
- Robust Ecosystem: Integration with security, data access, messaging, and more comes out of the box.
- Battle-Tested: Used by thousands of companies worldwide for mission-critical applications.
Real-World Use Cases
I've personally used Spring Boot REST APIs for:
- E-commerce platforms exposing product catalogs and order processing
- Banking applications providing account information and transaction history
- IoT systems collecting and analyzing sensor data
- Mobile backend services handling user authentication and data synchronization
- Internal tools for business process automation and reporting
Let's jump into building our product inventory API!
Building a Product Inventory REST API (Code Example)
Step 1: Set Up Your Spring Boot Project
First, create a new Spring Boot project using Spring Initializr (https://start.spring.io/) with the following dependencies:
- Spring Web
- Spring Data JPA
- H2 Database (for development)
- Validation
Step 2: Configure Your Application Properties
Create an application.properties file in src/main/resources:
# Database Configuration
spring.datasource.url=jdbc:h2:mem:productdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
# JPA Configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
# Server Configuration
server.port=8080
Step 3: Create the Domain Model
Let's create a Product entity:
package com.inventory.api.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Product name is required")
private String name;
private String description;
@NotNull(message = "Price is required")
@Min(value = 0, message = "Price must be greater than or equal to 0")
private Double price;
@Min(value = 0, message = "Quantity must be greater than or equal to 0")
private Integer quantity;
private String category;
// Constructors
public Product() {
}
public Product(String name, String description, Double price, Integer quantity, String category) {
this.name = name;
this.description = description;
this.price = price;
this.quantity = quantity;
this.category = category;
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Double getPrice() {
return price;
}
public void setPrice(Double price) {
this.price = price;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
}
Step 4: Create the Repository Layer
Create a repository interface to handle data operations:
package com.inventory.api.repository;
import com.inventory.api.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByCategory(String category);
List<Product> findByPriceLessThanEqual(Double price);
List<Product> findByNameContainingIgnoreCase(String name);
}
Step 5: Create a Service Layer
Create a service to handle business logic:
package com.inventory.api.service;
import com.inventory.api.model.Product;
import com.inventory.api.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class ProductService {
private final ProductRepository productRepository;
@Autowired
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<Product> getAllProducts() {
return productRepository.findAll();
}
public Optional<Product> getProductById(Long id) {
return productRepository.findById(id);
}
public List<Product> getProductsByCategory(String category) {
return productRepository.findByCategory(category);
}
public List<Product> getProductsByPriceLessThanEqual(Double price) {
return productRepository.findByPriceLessThanEqual(price);
}
public List<Product> searchProductsByName(String name) {
return productRepository.findByNameContainingIgnoreCase(name);
}
public Product createProduct(Product product) {
return productRepository.save(product);
}
public Optional<Product> updateProduct(Long id, Product productDetails) {
return productRepository.findById(id)
.map(existingProduct -> {
existingProduct.setName(productDetails.getName());
existingProduct.setDescription(productDetails.getDescription());
existingProduct.setPrice(productDetails.getPrice());
existingProduct.setQuantity(productDetails.getQuantity());
existingProduct.setCategory(productDetails.getCategory());
return productRepository.save(existingProduct);
});
}
public boolean deleteProduct(Long id) {
return productRepository.findById(id)
.map(product -> {
productRepository.delete(product);
return true;
})
.orElse(false);
}
}
Step 6: Create the REST Controller
Now, let's create the REST endpoints:
package com.inventory.api.controller;
import com.inventory.api.model.Product;
import com.inventory.api.service.ProductService;
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/products")
public class ProductController {
private final ProductService productService;
@Autowired
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public ResponseEntity<List<Product>> getAllProducts() {
return ResponseEntity.ok(productService.getAllProducts());
}
@GetMapping("/{id}")
public ResponseEntity<Product> getProductById(@PathVariable Long id) {
return productService.getProductById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/category/{category}")
public ResponseEntity<List<Product>> getProductsByCategory(@PathVariable String category) {
List<Product> products = productService.getProductsByCategory(category);
return products.isEmpty() ?
ResponseEntity.noContent().build() :
ResponseEntity.ok(products);
}
@GetMapping("/search")
public ResponseEntity<List<Product>> searchProducts(
@RequestParam(required = false) String name,
@RequestParam(required = false) Double maxPrice) {
List<Product> products;
if (name != null && !name.isEmpty()) {
products = productService.searchProductsByName(name);
} else if (maxPrice != null) {
products = productService.getProductsByPriceLessThanEqual(maxPrice);
} else {
products = productService.getAllProducts();
}
return products.isEmpty() ?
ResponseEntity.noContent().build() :
ResponseEntity.ok(products);
}
@PostMapping
public ResponseEntity<Product> createProduct(@Valid @RequestBody Product product) {
Product createdProduct = productService.createProduct(product);
return ResponseEntity.status(HttpStatus.CREATED).body(createdProduct);
}
@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(
@PathVariable Long id,
@Valid @RequestBody Product product) {
return productService.updateProduct(id, product)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
return productService.deleteProduct(id) ?
ResponseEntity.noContent().build() :
ResponseEntity.notFound().build();
}
}
Step 7: Create a Data Loader for Sample Data (Optional)
Let's add some sample data for testing:
package com.inventory.api.config;
import com.inventory.api.model.Product;
import com.inventory.api.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DataLoader {
@Bean
public CommandLineRunner loadData(ProductRepository repository) {
return args -> {
repository.save(new Product("Laptop", "High-performance gaming laptop", 1299.99, 10, "Electronics"));
repository.save(new Product("Smartphone", "Latest model with advanced camera", 799.99, 15, "Electronics"));
repository.save(new Product("Coffee Maker", "Programmable coffee machine", 99.95, 8, "Home Appliances"));
repository.save(new Product("Running Shoes", "Lightweight running shoes for professionals", 129.50, 20, "Sports"));
repository.save(new Product("Desk Chair", "Ergonomic office chair", 249.99, 5, "Furniture"));
};
}
}
Step 8: Create the Main Spring Boot Application Class
Complete your application with the main class:
package com.inventory.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ProductInventoryApiApplication {
public static void main(String[] args) {
SpringApplication.run(ProductInventoryApiApplication.class, args);
}
}
Explanation: Understanding the Components
The Domain Model
Our Product entity represents our data model with validation constraints:
- @Entity: Marks the class as a JPA entity that maps to a database table
- @Table: Specifies the table name as "products"
- @Id and @GeneratedValue: Set up auto-incrementing primary key
- @NotBlank and @Min: Add validation constraints to ensure data integrity
The Repository Layer
The repository interface enables database operations without writing SQL:
- By extending JpaRepository, we inherit CRUD operations like findAll(), findById(), save(), delete()
- Custom methods like findByCategory are automatically implemented based on their names - this is the "magic" of Spring Data
- We can create complex queries just by naming methods according to Spring Data's naming conventions
The Service Layer
Our service layer contains the business logic:
- Acts as an intermediary between the controller and repository
- Handles additional processing, validation, or transformation of data
- Makes our code more modular and testable by separating concerns
- Notice the use of Optional to handle potential null cases safely
The REST Controller
The controller defines our API endpoints:
- @RestController: Combines @Controller and @ResponseBody, meaning method return values become the response body
- @RequestMapping("/api/products"): Sets the base path for all endpoints
- @GetMapping, @PostMapping, etc.: Maps HTTP methods to controller methods
- ResponseEntity: Gives us control over HTTP status codes, headers, and body
- @PathVariable: Extracts values from the URL path
- @RequestParam: Extracts query parameters from the URL
- @Valid: Triggers validation based on annotations in the Product class
API Endpoints Overview
Our completed API provides these endpoints:
- GET /api/products: Retrieve all products
- GET /api/products/{id}: Get a specific product by ID
- GET /api/products/category/{category}: Get products by category
- GET /api/products/search: Search products by name or maximum price
- POST /api/products: Create a new product
- PUT /api/products/{id}: Update an existing product
- DELETE /api/products/{id}: Delete a product
Best Practices for REST API Development
1. Use Appropriate HTTP Methods
REST relies on using HTTP methods correctly:
- GET: Retrieve resources (should be idempotent - multiple identical requests have same effect)
- POST: Create new resources
- PUT: Update existing resources (typically the entire resource)
- PATCH: Partial update of a resource (not used in our example but useful for large objects)
- DELETE: Remove resources
2. Return Appropriate Status Codes
Notice in our controller how we use different status codes:
- 200 OK: Successful requests
- 201 Created: Resource successfully created
- 204 No Content: Success but nothing to return (used after DELETE)
- 404 Not Found: Resource not found
We should also handle other codes like:
- 400 Bad Request: Invalid input
- 401/403: Authentication/authorization failures
- 500: Server errors
3. Implement Validation
We use Jakarta validation annotations (@NotBlank, @Min) to validate input before processing:
@NotBlank(message = "Product name is required")
private String name;
@NotNull(message = "Price is required")
@Min(value = 0, message = "Price must be greater than or equal to 0")
private Double price;
This prevents invalid data from entering our system and provides clear feedback to API users.
4. Use DTOs for Complex APIs
For more complex APIs, consider using Data Transfer Objects (DTOs) to separate your API representation from your domain model:
// Example of a ProductDTO
public class ProductDTO {
private String name;
private String description;
private Double price;
// Omit internal fields like database IDs
// Include only what the API consumer needs to see
// Getters, setters, etc.
}
5. Implement Proper Exception Handling
Add a global exception handler to provide consistent error responses:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Object> handleResourceNotFoundException(ResourceNotFoundException ex) {
Map<String, String> error = new HashMap<>();
error.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// Add handlers for other exceptions
}
6. Document Your API
Use Springdoc OpenAPI (formerly Swagger) to document your API:
- Add the dependency to pom.xml:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.1.0</version>
</dependency>
- Add documentation annotations to your controller methods:
@Operation(summary = "Get all products", description = "Returns a list of all products in the inventory")
@GetMapping
public ResponseEntity<List<Product>> getAllProducts() {
// Method implementation
}
- Access the documentation at http://localhost:8080/swagger-ui.html
7. Implement Pagination for Large Collections
For endpoints that return large collections, implement pagination:
@GetMapping
public ResponseEntity<Page<Product>> getAllProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Product> productPage = productService.getAllProducts(pageable);
return ResponseEntity.ok(productPage);
}
8. Secure Your API
Add Spring Security to protect your endpoints. For a simple API, you can start with basic authentication:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // For API endpoints, often disabled
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
}
Conclusion
Building REST APIs with Spring Boot is surprisingly straightforward once you understand the core components. We've built a complete product inventory API with just a handful of Java classes, yet it's production-ready with validation, error handling, and clean organization.
What we've covered is just the beginning. As your API grows, you might want to explore:
- OAuth2 or JWT authentication for more sophisticated security
- API versioning strategies
- HATEOAS to make your API more discoverable
- Caching to improve performance
- Rate limiting to protect against abuse
The most important takeaway is the layered architecture pattern we've used:
- Controller: Handles HTTP requests/responses
- Service: Contains business logic
- Repository: Manages data access
- Entity: Represents the data model
This separation of concerns will serve you well as your application grows in complexity.
Remember that good APIs are more than just code—they're products used by developers. Keep your API intuitive, well-documented, and consistent. Your future self (and other developers) will thank you!